Skip to main content

projectatlas/
atlas_map.rs

1//! Purpose: Render and lint `ProjectAtlas` structure maps from Rust.
2
3use blake3::Hasher;
4use projectatlas_core::{
5    CanonicalProjectRoot, Node, NodeKind,
6    language::{BROAD_SOURCE_EXTENSIONS, canonical_language_id},
7    validated_repo_file_key,
8};
9use projectatlas_db::AtlasStore;
10use projectatlas_fs::{ScanOptions, explicit_language_override, scan_repo};
11use projectatlas_symbols::document_format_for_path;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use std::fs::{self, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18use thiserror::Error;
19use toml_edit::{Array, DocumentMut, Item, Table, value};
20
21/// Legacy folder-purpose filename accepted only as migration input.
22const DEFAULT_LEGACY_PURPOSE_FILENAME: &str = ".purpose";
23/// Default legacy map import path and adjacent JSON export location.
24const DEFAULT_MAP_PATH: &str = ".projectatlas/projectatlas.toon";
25/// Maximum bytes in a complete map response.
26pub(crate) const MAX_MAP_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
27/// Default non-source summary input path.
28const DEFAULT_NONSOURCE_PATH: &str = ".projectatlas/projectatlas-nonsource-files.toon";
29/// Durable `.projectatlas` inputs indexed by `SQLite` but ignored by legacy map/lint.
30const DURABLE_PROJECTATLAS_INPUT_PATHS: &[&str] = &[
31    ".projectatlas/config.toml",
32    ".projectatlas/projectatlas-nonsource-files.toon",
33    ".projectatlas/projectatlas-purpose-review.json",
34];
35/// Default maximum number of lines scanned for purpose headers.
36const DEFAULT_MAX_SCAN_LINES: usize = 80;
37/// Default maximum UTF-8 file size persisted into `SQLite` text search.
38pub(crate) const DEFAULT_TEXT_INDEX_MAX_BYTES: u64 = 2_000_000;
39/// Default maximum purpose summary length.
40const DEFAULT_SUMMARY_MAX_LENGTH: usize = 140;
41/// Ordered overview keys written into the TOON map.
42const OVERVIEW_KEYS: &[&str] = &[
43    "tracked_source_files",
44    "tracked_nonsource_files",
45    "tracked_files_total",
46    "tracked_folders",
47    "source_extensions",
48    "exclude_dir_names",
49    "exclude_path_prefixes",
50];
51/// Source extensions scanned for Purpose metadata by default.
52const DEFAULT_SOURCE_EXTENSIONS: &[&str] = BROAD_SOURCE_EXTENSIONS;
53/// Document extensions admitted by the bounded document parser in addition to
54/// the frozen v0.3.26 broad-source compatibility set.
55const DOCUMENT_SOURCE_EXTENSIONS: &[&str] = &[".pdf", ".docx"];
56/// Directory names excluded from scans even when config is hand-edited.
57const REQUIRED_EXCLUDE_DIR_NAMES: &[&str] = &[".git", ".projectatlas"];
58/// Directory names excluded from scans by default.
59const DEFAULT_EXCLUDE_DIR_NAMES: &[&str] = &[
60    ".cache",
61    ".egg-info",
62    ".git",
63    ".idea",
64    ".mypy_cache",
65    ".projectatlas",
66    ".pytest_cache",
67    ".tmp",
68    ".venv",
69    "__pycache__",
70    "artifacts",
71    "build",
72    "coverage",
73    "dist",
74    "node_modules",
75    "sandbox",
76    "target",
77    "temp",
78    "test-results",
79    "tmp",
80];
81/// Asset extensions recognized for untracked-file reporting.
82const DEFAULT_ASSET_EXTENSIONS: &[&str] = &[
83    ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".pdf", ".png", ".svg", ".ttf", ".webp", ".woff",
84    ".woff2",
85];
86/// Line comment prefixes supported by default.
87const DEFAULT_LINE_COMMENT_PREFIXES: &[&str] = &["//", "#", "--", ";"];
88
89/// Atlas map operation errors.
90#[derive(Debug, Error)]
91pub(crate) enum AtlasMapError {
92    /// A complete map cannot fit in the supported response budget.
93    #[error("map response exceeds byte limit: observed {observed}, limit {limit}")]
94    ResponseLimitExceeded {
95        /// Encoded response size.
96        observed: usize,
97        /// Maximum supported response size.
98        limit: usize,
99    },
100    /// Filesystem operation failed.
101    #[error("io error for {path:?}: {source}")]
102    Io {
103        /// Path involved in the failed operation.
104        path: PathBuf,
105        /// Source IO error.
106        source: std::io::Error,
107    },
108    /// TOML parsing failed.
109    #[error("toml parse error for {path:?}: {source}")]
110    Toml {
111        /// TOML path that failed to parse.
112        path: PathBuf,
113        /// Source TOML parse error.
114        source: Box<toml::de::Error>,
115    },
116    /// Filesystem scanner failed.
117    #[error("{0}")]
118    Scan(#[from] projectatlas_fs::FsError),
119    /// Durable index read failed.
120    #[error("database error for {path:?}: {message}")]
121    Database {
122        /// Database path that failed.
123        path: PathBuf,
124        /// Source database error text.
125        message: String,
126    },
127    /// Config or manual metadata referenced an unsafe repository path.
128    #[error("invalid repository-relative path {path:?}: {message}")]
129    InvalidRepositoryPath {
130        /// Invalid path text.
131        path: String,
132        /// Validation failure.
133        message: String,
134    },
135    /// A configured language selector or target is invalid.
136    #[error("invalid language override {selector:?} = {language:?}: {message}")]
137    InvalidLanguageOverride {
138        /// Exact filename or extension selector.
139        selector: String,
140        /// Requested canonical ID or alias.
141        language: String,
142        /// Validation failure.
143        message: String,
144    },
145    /// Editable TOML config was malformed for the requested operation.
146    #[error("toml edit error for {path:?}: {message}")]
147    TomlEdit {
148        /// Config path that failed to edit.
149        path: PathBuf,
150        /// TOML edit failure.
151        message: String,
152    },
153}
154
155/// Result alias for atlas map operations.
156type AtlasMapResult<T> = Result<T, AtlasMapError>;
157
158/// Raw deserialized config file.
159#[derive(Debug, Default, Deserialize)]
160struct RawConfig {
161    /// Project table.
162    project: Option<RawProject>,
163    /// Scan table.
164    scan: Option<RawScan>,
165    /// Purpose table.
166    purpose: Option<RawPurpose>,
167    /// Summary rules table.
168    summary_rules: Option<RawSummaryRules>,
169    /// Untracked policy table.
170    untracked: Option<RawUntracked>,
171}
172
173/// Raw project table.
174#[derive(Debug, Default, Deserialize)]
175struct RawProject {
176    /// Repository root.
177    root: Option<String>,
178    /// Legacy map import path and adjacent JSON sidecar location.
179    map_path: Option<String>,
180    /// Non-source file summary path.
181    nonsource_files_path: Option<String>,
182    /// Legacy manual file summary path.
183    manual_files_path: Option<String>,
184    /// Purpose filename.
185    purpose_filename: Option<String>,
186}
187
188/// Raw scan table.
189#[derive(Debug, Default, Deserialize)]
190struct RawScan {
191    /// Source extensions.
192    source_extensions: Option<Vec<String>>,
193    /// Excluded directory names.
194    exclude_dir_names: Option<Vec<String>>,
195    /// Excluded directory suffixes.
196    exclude_dir_suffixes: Option<Vec<String>>,
197    /// Excluded path prefixes.
198    exclude_path_prefixes: Option<Vec<String>>,
199    /// Non-source path prefixes.
200    non_source_path_prefixes: Option<Vec<String>>,
201    /// Maximum header scan lines.
202    max_scan_lines: Option<usize>,
203    /// Maximum UTF-8 file size persisted into `SQLite` text search.
204    text_index_max_bytes: Option<u64>,
205    /// Explicit exact-filename or extension language selections.
206    language_overrides: Option<BTreeMap<String, String>>,
207}
208
209/// Raw purpose table.
210#[derive(Debug, Default, Deserialize)]
211struct RawPurpose {
212    /// Default purpose header style.
213    default_style: Option<String>,
214    /// Line-comment prefixes.
215    line_comment_prefixes: Option<Vec<String>>,
216    /// Per-extension purpose styles.
217    styles_by_extension: Option<BTreeMap<String, String>>,
218}
219
220/// Raw summary-rules table.
221#[derive(Debug, Default, Deserialize)]
222struct RawSummaryRules {
223    /// Whether purpose summaries must be ASCII.
224    ascii_only: Option<bool>,
225    /// Whether purpose summaries may contain commas.
226    no_commas: Option<bool>,
227    /// Maximum purpose summary length.
228    max_length: Option<usize>,
229}
230
231/// Raw untracked table.
232#[derive(Debug, Default, Deserialize)]
233struct RawUntracked {
234    /// Allowed untracked filenames.
235    allowed_filenames: Option<Vec<String>>,
236    /// Allowed untracked directory prefixes.
237    allowlist_dir_prefixes: Option<Vec<String>>,
238    /// Allowed untracked files.
239    allowlist_files: Option<Vec<String>>,
240    /// Allowed asset prefixes.
241    asset_allowed_prefixes: Option<Vec<String>>,
242    /// Asset extensions.
243    asset_extensions: Option<Vec<String>>,
244}
245
246/// Normalized atlas map configuration.
247#[derive(Clone, Debug)]
248pub(crate) struct AtlasMapConfig {
249    /// Repository root.
250    pub(crate) root: PathBuf,
251    /// Lossless UTF-8 generated TOON map path, when one is available.
252    pub(crate) map_path: PathBuf,
253    /// Non-source file summary path.
254    pub(crate) nonsource_files_path: PathBuf,
255    /// Purpose filename.
256    purpose_filename: String,
257    /// Source extensions that require purpose headers.
258    source_extensions: BTreeSet<String>,
259    /// Excluded directory names.
260    exclude_dir_names: BTreeSet<String>,
261    /// Excluded directory suffixes.
262    exclude_dir_suffixes: BTreeSet<String>,
263    /// Excluded repository-relative prefixes.
264    exclude_path_prefixes: BTreeSet<String>,
265    /// Prefixes treated as non-source even when extensions match.
266    non_source_path_prefixes: BTreeSet<String>,
267    /// Validated exact-filename or extension language overrides.
268    language_overrides: BTreeMap<String, String>,
269    /// Allowed untracked filenames.
270    allowed_untracked_filenames: BTreeSet<String>,
271    /// Allowed untracked directory prefixes.
272    untracked_allowlist_dir_prefixes: BTreeSet<String>,
273    /// Allowed untracked files.
274    untracked_allowlist_files: BTreeSet<String>,
275    /// Allowed asset root prefixes.
276    asset_allowed_prefixes: BTreeSet<String>,
277    /// Asset extensions.
278    asset_extensions: BTreeSet<String>,
279    /// Durable `SQLite` index path.
280    db_path: PathBuf,
281    /// Maximum lines to scan for purpose headers.
282    max_scan_lines: usize,
283    /// Maximum UTF-8 file size persisted into `SQLite` text search.
284    text_index_max_bytes: u64,
285    /// Maximum purpose summary length.
286    summary_max_length: usize,
287    /// Whether summaries must be ASCII.
288    summary_ascii_only: bool,
289    /// Whether summaries may contain commas.
290    summary_no_commas: bool,
291    /// Per-extension purpose styles.
292    purpose_styles: BTreeMap<String, String>,
293    /// Default purpose style.
294    purpose_default_style: String,
295    /// Supported line-comment prefixes.
296    line_comment_prefixes: Vec<String>,
297}
298
299impl AtlasMapConfig {
300    /// Bind database-backed map and lint work to the selected runtime database.
301    pub(crate) fn with_database_path(mut self, database_path: &Path) -> Self {
302        self.db_path = database_path.to_path_buf();
303        self
304    }
305
306    /// Return scanner options derived from the normalized project config.
307    pub(crate) fn scan_options(&self) -> ScanOptions {
308        ScanOptions {
309            exclude_dir_names: self.exclude_dir_names.iter().cloned().collect(),
310            exclude_dir_suffixes: self.exclude_dir_suffixes.iter().cloned().collect(),
311            exclude_path_prefixes: self.exclude_path_prefixes.iter().cloned().collect(),
312            language_overrides: self.language_overrides.clone(),
313            admit_optional_languages: false,
314        }
315    }
316
317    /// Return the configured maximum UTF-8 file size for `SQLite` text search.
318    pub(crate) fn text_index_max_bytes(&self) -> u64 {
319        self.text_index_max_bytes
320    }
321
322    /// Return the configured legacy folder-purpose filename.
323    pub(crate) fn purpose_filename(&self) -> &str {
324        &self.purpose_filename
325    }
326}
327
328/// Serializable view of the effective `ProjectAtlas` configuration.
329#[derive(Debug, Serialize)]
330pub(crate) struct EffectiveConfigReport {
331    /// Lossless UTF-8 repository root, when one is available.
332    pub(crate) root: Option<String>,
333    /// Lossless UTF-8 generated TOON map path, when one is available.
334    pub(crate) map_path: Option<String>,
335    /// Lossless UTF-8 non-source purpose registry path, when one is available.
336    pub(crate) nonsource_files_path: Option<String>,
337    /// Lossless UTF-8 durable `SQLite` index path, when one is available.
338    pub(crate) db_path: Option<String>,
339    /// Purpose metadata filename.
340    pub(crate) purpose_filename: String,
341    /// Source extensions treated as indexable project content.
342    pub(crate) source_extensions: Vec<String>,
343    /// Directory names excluded from normal scans.
344    pub(crate) exclude_dir_names: Vec<String>,
345    /// Repository-relative path prefixes excluded from normal scans.
346    pub(crate) exclude_path_prefixes: Vec<String>,
347    /// Configured non-source path prefixes.
348    pub(crate) non_source_path_prefixes: Vec<String>,
349    /// Validated exact-filename or extension language overrides.
350    pub(crate) language_overrides: BTreeMap<String, String>,
351    /// Default purpose style.
352    pub(crate) purpose_default_style: String,
353    /// Per-extension purpose style overrides.
354    pub(crate) purpose_styles: BTreeMap<String, String>,
355    /// Supported line comment prefixes for purpose headers.
356    pub(crate) line_comment_prefixes: Vec<String>,
357    /// Maximum file size persisted into `SQLite` text search.
358    pub(crate) text_index_max_bytes: u64,
359    /// Maximum purpose line length.
360    pub(crate) summary_max_length: usize,
361    /// Whether purpose summaries must be ASCII.
362    pub(crate) summary_ascii_only: bool,
363    /// Whether purpose summaries may not contain commas.
364    pub(crate) summary_no_commas: bool,
365}
366
367/// Build the effective configuration report used by agents and docs.
368pub(crate) fn effective_config_report(config: &AtlasMapConfig) -> EffectiveConfigReport {
369    EffectiveConfigReport {
370        root: CanonicalProjectRoot::from_path(&config.root)
371            .ok()
372            .and_then(|root| root.display_string().ok()),
373        map_path: effective_config_path_display(&config.map_path),
374        nonsource_files_path: effective_config_path_display(&config.nonsource_files_path),
375        db_path: effective_config_path_display(&config.db_path),
376        purpose_filename: config.purpose_filename.clone(),
377        source_extensions: config.source_extensions.iter().cloned().collect(),
378        exclude_dir_names: config.exclude_dir_names.iter().cloned().collect(),
379        exclude_path_prefixes: config.exclude_path_prefixes.iter().cloned().collect(),
380        non_source_path_prefixes: config.non_source_path_prefixes.iter().cloned().collect(),
381        language_overrides: config.language_overrides.clone(),
382        purpose_default_style: config.purpose_default_style.clone(),
383        purpose_styles: config.purpose_styles.clone(),
384        line_comment_prefixes: config.line_comment_prefixes.clone(),
385        text_index_max_bytes: config.text_index_max_bytes,
386        summary_max_length: config.summary_max_length,
387        summary_ascii_only: config.summary_ascii_only,
388        summary_no_commas: config.summary_no_commas,
389    }
390}
391
392/// Render a native path without replacing non-UTF-8 units.
393#[cfg(windows)]
394fn effective_config_path_display(path: &Path) -> Option<String> {
395    projectatlas_core::lossless_native_path_display(path)
396        .ok()
397        .map(|value| value.replace('/', "\\"))
398}
399
400/// Render a native path without replacing non-UTF-8 units.
401#[cfg(not(windows))]
402fn effective_config_path_display(path: &Path) -> Option<String> {
403    path.to_str().map(ToOwned::to_owned)
404}
405
406/// `ProjectAtlas` map record.
407#[derive(Clone, Debug, Eq, PartialEq)]
408struct MapRecord {
409    /// Repository-relative path.
410    path: String,
411    /// One-line purpose summary.
412    summary: String,
413    /// Source of the summary.
414    source: String,
415}
416
417/// Snapshot rendered as a current `ProjectAtlas` map.
418#[derive(Debug)]
419struct AtlasSnapshot {
420    /// Folder records.
421    folder_records: Vec<MapRecord>,
422    /// File records.
423    file_records: Vec<MapRecord>,
424    /// Folder tree lines.
425    folder_tree: Vec<String>,
426    /// Duplicate folder summaries.
427    folder_duplicates: Vec<String>,
428    /// Duplicate file summaries.
429    file_duplicates: Vec<String>,
430    /// File record hash.
431    file_hash: String,
432    /// Folder record hash.
433    folder_hash: String,
434    /// Generated timestamp.
435    generated_at: String,
436    /// Overview counters.
437    overview: BTreeMap<String, usize>,
438}
439
440/// Result of collecting repository paths.
441#[derive(Debug)]
442struct RepoPaths {
443    /// Folder paths.
444    folders: Vec<String>,
445    /// Source file paths.
446    source_files: Vec<String>,
447    /// Non-source file paths.
448    untracked_files: Vec<String>,
449    /// Excluded paths that exist.
450    excluded_paths: Vec<String>,
451}
452
453/// Parsed non-source entry set and validation state.
454#[derive(Debug)]
455struct NonsourceEntries {
456    /// Valid or placeholder records.
457    records: Vec<MapRecord>,
458    /// Entries pointing to missing paths.
459    missing: Vec<String>,
460    /// Entries with invalid summaries.
461    invalid: BTreeMap<String, Vec<String>>,
462    /// File-level parsing errors.
463    errors: Vec<String>,
464}
465
466/// Lint options supplied by the CLI.
467#[derive(Clone, Copy, Debug)]
468pub(crate) struct LintOptions {
469    /// Deprecated compatibility flag accepted by the CLI.
470    pub(crate) strict_folders: bool,
471    /// Whether to print untracked-file report.
472    pub(crate) report_untracked: bool,
473    /// Whether untracked files fail lint.
474    pub(crate) strict_untracked: bool,
475}
476
477/// Typed map-owned portion of one lint result.
478#[derive(Debug, Eq, PartialEq, Serialize)]
479pub(crate) struct MapLintReport {
480    /// Whether every map-owned check passed.
481    pub(crate) ok: bool,
482    /// Compatibility notices that do not fail lint.
483    pub(crate) notes: Vec<String>,
484    /// Validation state for the non-source purpose registry.
485    pub(crate) non_source: NonSourceLintReport,
486    /// Untracked-file inventory when requested.
487    pub(crate) untracked: Option<UntrackedLintReport>,
488}
489
490/// Typed non-source purpose-registry lint facts.
491#[derive(Debug, Eq, PartialEq, Serialize)]
492pub(crate) struct NonSourceLintReport {
493    /// File-level parse or validation errors.
494    pub(crate) errors: Vec<String>,
495    /// Registry entries whose paths do not exist.
496    pub(crate) missing: Vec<String>,
497    /// Registry entries with invalid summaries, keyed by path.
498    pub(crate) invalid: BTreeMap<String, Vec<String>>,
499}
500
501/// Typed untracked-file lint inventory.
502#[derive(Debug, Eq, PartialEq, Serialize)]
503pub(crate) struct UntrackedLintReport {
504    /// Total untracked non-source files found.
505    pub(crate) total: usize,
506    /// Files admitted by configured purpose or allow-list policy.
507    pub(crate) allowed: usize,
508    /// Files not admitted by configured purpose or allow-list policy.
509    pub(crate) disallowed: Vec<String>,
510    /// Disallowed file counts grouped by normalized extension.
511    pub(crate) disallowed_extension_counts: BTreeMap<String, usize>,
512    /// Allowed file counts grouped by normalized extension.
513    pub(crate) allowed_extension_counts: BTreeMap<String, usize>,
514    /// Configured asset roots that currently exist.
515    pub(crate) asset_roots_present: usize,
516    /// Asset files found outside configured asset roots.
517    pub(crate) assets_outside_roots: Vec<String>,
518    /// Existing paths excluded from source scanning.
519    pub(crate) excluded_paths_present: usize,
520    /// Whether disallowed untracked files fail lint.
521    pub(crate) strict: bool,
522}
523
524impl MapLintReport {
525    /// Return the CLI-compatible exit code for map-owned findings.
526    pub(crate) const fn exit_code(&self) -> i32 {
527        if self.ok { 0 } else { 1 }
528    }
529
530    /// Render the compatibility text from the same typed facts.
531    pub(crate) fn render_text(&self) -> String {
532        let mut report = self.notes.clone();
533        if let Some(untracked) = &self.untracked {
534            untracked.append_text(&mut report);
535        }
536        self.non_source.append_errors(&mut report);
537        if self
538            .untracked
539            .as_ref()
540            .is_some_and(|untracked| untracked.strict && !untracked.disallowed.is_empty())
541        {
542            report.push("Untracked files detected.".to_string());
543        }
544        join_report(&report)
545    }
546}
547
548impl NonSourceLintReport {
549    /// Return whether the non-source registry contains blocking findings.
550    fn is_empty(&self) -> bool {
551        self.errors.is_empty() && self.missing.is_empty() && self.invalid.is_empty()
552    }
553
554    /// Append compatibility text for blocking non-source findings.
555    fn append_errors(&self, report: &mut Vec<String>) {
556        if !self.errors.is_empty() {
557            report.push("Non-source file list errors:".to_string());
558            report.push(format_list(&self.errors));
559        }
560        if !self.missing.is_empty() {
561            report.push("Missing non-source file entries:".to_string());
562            report.push(format_list(&self.missing));
563        }
564        if !self.invalid.is_empty() {
565            report.push("Invalid non-source file summaries:".to_string());
566            report.extend(
567                self.invalid
568                    .iter()
569                    .map(|(path, issues)| format!(" - {path}: {}", issues.join(", "))),
570            );
571        }
572    }
573}
574
575impl UntrackedLintReport {
576    /// Append compatibility text for the typed untracked-file inventory.
577    fn append_text(&self, report: &mut Vec<String>) {
578        report.push(format!(
579            "Untracked files (non-source extensions): {} (allowed {}, disallowed {})",
580            self.total,
581            self.allowed,
582            self.disallowed.len()
583        ));
584        if self.disallowed.is_empty() {
585            report.push("Disallowed untracked files: 0".to_string());
586        } else {
587            report.push("Disallowed untracked files:".to_string());
588            report.push(format_list(&self.disallowed));
589            report.push("Disallowed extension counts:".to_string());
590            report.push(format_extension_counts(&self.disallowed_extension_counts));
591        }
592        report.push("Allowed untracked extension counts:".to_string());
593        report.push(if self.allowed_extension_counts.is_empty() {
594            " (none)".to_string()
595        } else {
596            format_extension_counts(&self.allowed_extension_counts)
597        });
598        report.push(format!("Asset roots present: {}", self.asset_roots_present));
599        if !self.assets_outside_roots.is_empty() {
600            report.push("Asset files outside allowed roots:".to_string());
601            report.push(format_list(&self.assets_outside_roots));
602        }
603        report.push(format!(
604            "Excluded paths present: {}",
605            self.excluded_paths_present
606        ));
607    }
608}
609
610/// Purpose record imported from legacy `ProjectAtlas` metadata.
611#[derive(Clone, Debug, Eq, PartialEq)]
612pub(crate) struct ImportedPurposeRecord {
613    /// Repository-relative path.
614    pub(crate) path: String,
615    /// Imported purpose summary.
616    pub(crate) summary: String,
617}
618
619/// Manual `ProjectAtlas` ignore entry kind.
620#[derive(Clone, Copy, Debug, Eq, PartialEq)]
621pub(crate) enum IgnoreEntryKind {
622    /// Exclude every directory with this name anywhere under the project root.
623    DirName,
624    /// Exclude one repository-relative path subtree.
625    PathPrefix,
626}
627
628impl IgnoreEntryKind {
629    /// Stable config key for this ignore kind.
630    fn config_key(self) -> &'static str {
631        match self {
632            Self::DirName => "exclude_dir_names",
633            Self::PathPrefix => "exclude_path_prefixes",
634        }
635    }
636
637    /// Agent-facing ignore kind name.
638    fn as_str(self) -> &'static str {
639        match self {
640            Self::DirName => "dir-name",
641            Self::PathPrefix => "path-prefix",
642        }
643    }
644}
645
646/// Current `ProjectAtlas` ignore configuration report.
647#[derive(Debug, Serialize)]
648pub(crate) struct IgnoreListReport {
649    /// Lossless UTF-8 config path used for the manual `ProjectAtlas` ignore layer.
650    pub(crate) config_path: Option<String>,
651    /// Lossless UTF-8 `.gitignore` path that the scanner will honor when it exists.
652    pub(crate) gitignore_path: Option<String>,
653    /// Whether a `.gitignore` file currently exists at the project root.
654    pub(crate) gitignore_present: bool,
655    /// Scanner behavior for `.gitignore`.
656    pub(crate) gitignore_mode: String,
657    /// Order of the manual `ProjectAtlas` ignore layer.
658    pub(crate) manual_layer_order: String,
659    /// Effective directory-name excludes after defaults and config are applied.
660    pub(crate) exclude_dir_names: Vec<String>,
661    /// Effective repository-relative path-prefix excludes.
662    pub(crate) exclude_path_prefixes: Vec<String>,
663}
664
665/// Result of creating a project-root `.gitignore` when it is missing.
666#[derive(Debug, Serialize)]
667pub(crate) struct GitignoreInitReport {
668    /// Lossless UTF-8 `.gitignore` path that was checked, when one is available.
669    pub(crate) gitignore_path: Option<String>,
670    /// Whether the file already existed before the command.
671    pub(crate) existed: bool,
672    /// Whether the command created the file.
673    pub(crate) created: bool,
674    /// Whether `.gitignore` rules are inherited dynamically by the scanner.
675    pub(crate) gitignore_inherited: bool,
676}
677
678/// Result of adding or removing a manual `ProjectAtlas` ignore entry.
679#[derive(Debug, Serialize)]
680pub(crate) struct IgnoreMutationReport {
681    /// Lossless UTF-8 config path that was edited, when one is available.
682    pub(crate) config_path: Option<String>,
683    /// Lossless UTF-8 `.gitignore` path that the scanner will honor when it exists.
684    pub(crate) gitignore_path: Option<String>,
685    /// Whether a `.gitignore` file currently exists at the project root.
686    pub(crate) gitignore_present: bool,
687    /// Mutation action.
688    pub(crate) action: String,
689    /// Ignore kind that was targeted, or `any` for a broad remove.
690    pub(crate) kind: String,
691    /// Normalized ignore value.
692    pub(crate) value: String,
693    /// Whether the config file changed.
694    pub(crate) changed: bool,
695    /// Scanner behavior for `.gitignore`.
696    pub(crate) gitignore_mode: String,
697    /// Order of the manual `ProjectAtlas` ignore layer.
698    pub(crate) manual_layer_order: String,
699    /// Effective directory-name excludes after the mutation.
700    pub(crate) exclude_dir_names: Vec<String>,
701    /// Effective repository-relative path-prefix excludes after the mutation.
702    pub(crate) exclude_path_prefixes: Vec<String>,
703}
704
705/// Load atlas map configuration from disk.
706pub(crate) fn load_atlas_config(config_path: Option<&Path>) -> AtlasMapResult<AtlasMapConfig> {
707    let cwd = std::env::current_dir().map_err(|source| AtlasMapError::Io {
708        path: PathBuf::from("."),
709        source,
710    })?;
711    let config_file = match config_path {
712        Some(path) => Some(path.to_path_buf()),
713        None => find_config_path(&cwd),
714    };
715    if let Some(path) = &config_file {
716        let text = fs::read_to_string(path).map_err(|source| AtlasMapError::Io {
717            path: path.clone(),
718            source,
719        })?;
720        return load_atlas_config_from_text(path, &text);
721    }
722    normalize_config(RawConfig::default(), None, &cwd, &cwd)
723}
724
725/// Parse an already-bounded configuration input using normal path semantics.
726pub(crate) fn load_atlas_config_from_text(
727    path: &Path,
728    text: &str,
729) -> AtlasMapResult<AtlasMapConfig> {
730    let cwd = std::env::current_dir().map_err(|source| AtlasMapError::Io {
731        path: PathBuf::from("."),
732        source,
733    })?;
734    let parsed = toml::from_str::<RawConfig>(text).map_err(|source| AtlasMapError::Toml {
735        path: path.to_path_buf(),
736        source: Box::new(source),
737    })?;
738    let base_dir = path.parent().map_or_else(|| cwd.clone(), Path::to_path_buf);
739    normalize_config(parsed, Some(path), &base_dir, &cwd)
740}
741
742/// Load atlas map configuration for an explicit project root.
743pub(crate) fn load_atlas_config_for_root(root: &Path) -> AtlasMapResult<AtlasMapConfig> {
744    if let Some(config_path) = find_config_path(root) {
745        return load_atlas_config(Some(&config_path));
746    }
747    normalize_config(RawConfig::default(), None, root, root)
748}
749
750/// Write default `ProjectAtlas` config files, honoring an explicitly selected config path.
751pub(crate) fn init_project_with_config(
752    root: &Path,
753    selected_config: Option<&Path>,
754) -> AtlasMapResult<String> {
755    let canonical_root = fs::canonicalize(root).map_err(|source| AtlasMapError::Io {
756        path: root.to_path_buf(),
757        source,
758    })?;
759    let root = canonical_root.as_path();
760    let project_dir = root.join(".projectatlas");
761    let nested_config_path = project_dir.join("config.toml");
762    let flat_config_path = root.join("projectatlas.toml");
763    let config_path = selected_config.map_or_else(
764        || {
765            if nested_config_path.exists() {
766                nested_config_path.clone()
767            } else if flat_config_path.exists() {
768                flat_config_path
769            } else {
770                nested_config_path.clone()
771            }
772        },
773        Path::to_path_buf,
774    );
775    let mut state =
776        crate::project_state::ProjectStateGuard::preflight(root, None, Some(&config_path))
777            .map_err(|source| AtlasMapError::Io {
778                path: project_dir.clone(),
779                source,
780            })?;
781    let default_config = if config_path.exists() {
782        None
783    } else {
784        Some(default_config_text_for(root, &config_path)?)
785    };
786    let mut config_document = if default_config.is_some() {
787        crate::project_state::ProjectStateGuard::preflight_document(root, &config_path).map_err(
788            |source| AtlasMapError::Io {
789                path: config_path.clone(),
790                source,
791            },
792        )?
793    } else {
794        None
795    };
796    state
797        .ensure_directory()
798        .map_err(|source| AtlasMapError::Io {
799            path: project_dir.clone(),
800            source,
801        })?;
802    if let Some(default_config) = default_config {
803        if let Some(parent) = config_path
804            .parent()
805            .filter(|path| !path.as_os_str().is_empty())
806        {
807            fs::create_dir_all(parent).map_err(|source| AtlasMapError::Io {
808                path: parent.to_path_buf(),
809                source,
810            })?;
811        }
812        state.verify().map_err(|source| AtlasMapError::Io {
813            path: project_dir.clone(),
814            source,
815        })?;
816        // The document role owns both fixed-name admission and creation mode.
817        let written = if let Some(document) = &mut config_document {
818            document
819                .create_document(default_config.as_bytes())
820                .map(|_| ())
821        } else {
822            // This destination was explicitly selected by the operator.
823            fs::write(&config_path, default_config)
824        };
825        written.map_err(|source| AtlasMapError::Io {
826            path: config_path.clone(),
827            source,
828        })?;
829    }
830    let nonsource_path = project_dir.join("projectatlas-nonsource-files.toon");
831    if !nonsource_path.exists() {
832        state.verify().map_err(|source| AtlasMapError::Io {
833            path: project_dir.clone(),
834            source,
835        })?;
836        state
837            .write_new(&nonsource_path, b"nonsource_files[]:\n  # path,summary\n")
838            .map_err(|source| AtlasMapError::Io {
839                path: nonsource_path.clone(),
840                source,
841            })?;
842    }
843    Ok(String::new())
844}
845
846/// List effective `ProjectAtlas` ignore policy.
847pub(crate) fn list_ignore_entries(
848    config_path: Option<&Path>,
849    project_root: &Path,
850) -> AtlasMapResult<IgnoreListReport> {
851    let path = resolve_config_edit_path(config_path, project_root)?;
852    let config = if path.exists() {
853        load_atlas_config(Some(&path))?
854    } else {
855        load_atlas_config_for_root(project_root)?
856    };
857    Ok(ignore_list_report(&path, &config))
858}
859
860/// Create a project-root `.gitignore` when it is missing.
861pub(crate) fn init_gitignore(
862    config_path: Option<&Path>,
863    project_root: &Path,
864) -> AtlasMapResult<GitignoreInitReport> {
865    let path = resolve_config_edit_path(config_path, project_root)?;
866    let config = if path.exists() {
867        load_atlas_config(Some(&path))?
868    } else {
869        load_atlas_config_for_root(project_root)?
870    };
871    let gitignore_path = config.root.join(".gitignore");
872    let existed = match fs::symlink_metadata(&gitignore_path) {
873        Ok(_) => true,
874        Err(source) if source.kind() == io::ErrorKind::NotFound => false,
875        Err(source) => {
876            return Err(AtlasMapError::Io {
877                path: gitignore_path,
878                source,
879            });
880        }
881    };
882    let created = if existed {
883        false
884    } else {
885        let mut guard = crate::project_state::ProjectStateGuard::preflight_document(
886            &config.root,
887            &gitignore_path,
888        )
889        .map_err(|source| AtlasMapError::Io {
890            path: gitignore_path.clone(),
891            source,
892        })?
893        .ok_or_else(|| AtlasMapError::Io {
894            path: gitignore_path.clone(),
895            source: io::Error::new(
896                io::ErrorKind::InvalidInput,
897                "gitignore has no admitted project owner",
898            ),
899        })?;
900        guard
901            .create_document(default_gitignore_text().as_bytes())
902            .map_err(|source| AtlasMapError::Io {
903                path: gitignore_path.clone(),
904                source,
905            })?
906    };
907    Ok(GitignoreInitReport {
908        gitignore_path: effective_config_path_display(&gitignore_path),
909        existed: existed || !created,
910        created,
911        gitignore_inherited: true,
912    })
913}
914
915/// Add one manual `ProjectAtlas` ignore entry to config.
916pub(crate) fn add_ignore_entry(
917    config_path: Option<&Path>,
918    project_root: &Path,
919    kind: IgnoreEntryKind,
920    value: &str,
921) -> AtlasMapResult<IgnoreMutationReport> {
922    let normalized = normalize_ignore_value(kind, value)?;
923    let path = resolve_config_edit_path(config_path, project_root)?;
924    let mut document = load_config_document_for_edit(&path)?;
925    let mut values = string_array_values(&path, &document, kind)?;
926    let changed = values.insert(normalized.clone());
927    if changed {
928        write_string_array(&mut document, kind, &values)?;
929        write_config_document(&path, &document, project_root)?;
930    }
931    let config = load_atlas_config(Some(&path))?;
932    Ok(ignore_mutation_report(
933        &path,
934        "add",
935        kind.as_str(),
936        &normalized,
937        changed,
938        &config,
939    ))
940}
941
942/// Remove one manual `ProjectAtlas` ignore entry from config.
943pub(crate) fn remove_ignore_entry(
944    config_path: Option<&Path>,
945    project_root: &Path,
946    kind: Option<IgnoreEntryKind>,
947    value: &str,
948) -> AtlasMapResult<IgnoreMutationReport> {
949    let path = resolve_config_edit_path(config_path, project_root)?;
950    let mut document = load_config_document_for_edit(&path)?;
951    let mut changed = false;
952    let normalized = if let Some(kind) = kind {
953        let normalized = normalize_ignore_value(kind, value)?;
954        let mut values = string_array_values(&path, &document, kind)?;
955        if values.remove(&normalized) {
956            changed = true;
957            write_string_array(&mut document, kind, &values)?;
958        }
959        normalized
960    } else {
961        let normalized_prefix = normalize_ignore_value(IgnoreEntryKind::PathPrefix, value)?;
962        let normalized_dir = normalize_ignore_value(IgnoreEntryKind::DirName, value).ok();
963        let mut prefix_values = string_array_values(&path, &document, IgnoreEntryKind::PathPrefix)?;
964        if prefix_values.remove(&normalized_prefix) {
965            changed = true;
966            write_string_array(&mut document, IgnoreEntryKind::PathPrefix, &prefix_values)?;
967        }
968        if let Some(normalized_dir) = normalized_dir.as_deref() {
969            let mut dir_values = string_array_values(&path, &document, IgnoreEntryKind::DirName)?;
970            if dir_values.remove(normalized_dir) {
971                changed = true;
972                write_string_array(&mut document, IgnoreEntryKind::DirName, &dir_values)?;
973            }
974        }
975        normalized_prefix
976    };
977    if changed {
978        write_config_document(&path, &document, project_root)?;
979    }
980    let config = load_atlas_config(Some(&path))?;
981    Ok(ignore_mutation_report(
982        &path,
983        "remove",
984        kind.map_or("any", IgnoreEntryKind::as_str),
985        &normalized,
986        changed,
987        &config,
988    ))
989}
990
991/// Complete current map response and optional sidecar state.
992pub(crate) struct MapResponse {
993    /// Complete selected-format map content.
994    pub(crate) content: String,
995}
996
997/// Render a current map without writing a legacy TOON snapshot.
998pub(crate) fn render_map(
999    config: &AtlasMapConfig,
1000    json_response: bool,
1001    write_json: bool,
1002) -> AtlasMapResult<MapResponse> {
1003    render_map_with_limit(config, json_response, write_json, MAX_MAP_RESPONSE_BYTES)
1004}
1005
1006/// Render under an explicit budget so refusal and no-write order are testable.
1007fn render_map_with_limit(
1008    config: &AtlasMapConfig,
1009    json_response: bool,
1010    write_json: bool,
1011    response_limit: usize,
1012) -> AtlasMapResult<MapResponse> {
1013    let snapshot = build_snapshot(config)?;
1014    let content = if json_response {
1015        render_json_map(&snapshot)?
1016    } else {
1017        render_toon(&snapshot, config)
1018    };
1019    enforce_map_response_limit(&content, response_limit)?;
1020    if write_json {
1021        if json_response {
1022            write_json_map(&content, config)?;
1023        } else {
1024            write_json_map(&render_json_map(&snapshot)?, config)?;
1025        }
1026    }
1027    Ok(MapResponse { content })
1028}
1029
1030/// Refuse a partial response before any optional file mutation.
1031pub(crate) fn enforce_map_response_limit(content: &str, limit: usize) -> AtlasMapResult<()> {
1032    if content.len() > limit {
1033        return Err(AtlasMapError::ResponseLimitExceeded {
1034            observed: content.len(),
1035            limit,
1036        });
1037    }
1038    Ok(())
1039}
1040
1041/// Extract approved legacy purpose records from an existing controlled scan.
1042///
1043/// The caller owns text-input cancellation and byte accounting through
1044/// `read_text`; this function never starts a second repository scan.
1045pub(crate) fn imported_purpose_records_from_nodes<E, F>(
1046    config: &AtlasMapConfig,
1047    nodes: &[Node],
1048    read_text: &mut F,
1049) -> Result<Vec<ImportedPurposeRecord>, E>
1050where
1051    E: From<AtlasMapError>,
1052    F: FnMut(&Path) -> Result<String, E>,
1053{
1054    let paths = repo_paths_from_nodes(config, nodes);
1055    imported_purpose_records_from_paths(config, &paths, read_text)
1056}
1057
1058/// Extract approved purpose rows from one already-selected path inventory.
1059fn imported_purpose_records_from_paths<E, F>(
1060    config: &AtlasMapConfig,
1061    paths: &RepoPaths,
1062    read_text: &mut F,
1063) -> Result<Vec<ImportedPurposeRecord>, E>
1064where
1065    E: From<AtlasMapError>,
1066    F: FnMut(&Path) -> Result<String, E>,
1067{
1068    let mut imported = BTreeMap::new();
1069    append_existing_map_purpose_records_with_reader(config, &mut imported, read_text)?;
1070    let db_purposes = BTreeMap::new();
1071    let (file_records, _, _) =
1072        build_file_records_with_reader(&paths.source_files, config, &db_purposes, read_text)?;
1073    let nonsource = read_nonsource_file_entries_with_reader(config, read_text)?;
1074    let merged_file_records = merge_records(&file_records, &nonsource.records);
1075    let (folder_records, _, _) =
1076        build_folder_records_with_reader(&paths.folders, config, &db_purposes, read_text)?;
1077    append_imported_records(&mut imported, &folder_records);
1078    append_imported_records(&mut imported, &merged_file_records);
1079    Ok(imported
1080        .into_iter()
1081        .map(|(path, summary)| ImportedPurposeRecord { path, summary })
1082        .collect())
1083}
1084
1085/// Append valid imported records from map records.
1086fn append_imported_records(imported: &mut BTreeMap<String, String>, records: &[MapRecord]) {
1087    for record in records {
1088        if record.summary == "MISSING" || record.summary == "INVALID" {
1089            continue;
1090        }
1091        imported.insert(record.path.clone(), record.summary.clone());
1092    }
1093}
1094
1095/// Append approved records from an existing committed atlas map.
1096#[cfg(test)]
1097fn append_existing_map_purpose_records(
1098    config: &AtlasMapConfig,
1099    imported: &mut BTreeMap<String, String>,
1100) -> AtlasMapResult<()> {
1101    let mut read_text = read_text_file;
1102    append_existing_map_purpose_records_with_reader(config, imported, &mut read_text)
1103}
1104
1105/// Append approved map rows using the caller-owned text reader.
1106fn append_existing_map_purpose_records_with_reader<E, F>(
1107    config: &AtlasMapConfig,
1108    imported: &mut BTreeMap<String, String>,
1109    read_text: &mut F,
1110) -> Result<(), E>
1111where
1112    E: From<AtlasMapError>,
1113    F: FnMut(&Path) -> Result<String, E>,
1114{
1115    if !config.map_path.exists() {
1116        return Ok(());
1117    }
1118    let content = read_text(&config.map_path)?;
1119    let mut in_record_rows = false;
1120    for line in content.lines().map(str::trim) {
1121        if line.starts_with("folders[") || line.starts_with("files[") {
1122            in_record_rows = true;
1123            continue;
1124        }
1125        if line.ends_with(':') {
1126            in_record_rows = false;
1127            continue;
1128        }
1129        if !in_record_rows || line.is_empty() || line.starts_with('#') {
1130            continue;
1131        }
1132        let cells = split_record_cells(line);
1133        if cells.len() < 2 {
1134            continue;
1135        }
1136        let summary = cells[1].trim();
1137        if summary.is_empty() || summary == "MISSING" || summary == "INVALID" {
1138            continue;
1139        }
1140        let path = normalize_repo_string(&cells[0]).map_err(E::from)?;
1141        imported.insert(path, summary.to_string());
1142    }
1143    Ok(())
1144}
1145
1146/// Read one complete UTF-8 atlas-map input through the compatibility path.
1147fn read_text_file(path: &Path) -> AtlasMapResult<String> {
1148    fs::read_to_string(path).map_err(|source| AtlasMapError::Io {
1149        path: path.to_path_buf(),
1150        source,
1151    })
1152}
1153
1154/// Load approved purpose records from the durable `SQLite` index.
1155fn load_db_purpose_records(config: &AtlasMapConfig) -> AtlasMapResult<BTreeMap<String, String>> {
1156    if !config.db_path.exists() {
1157        return Ok(BTreeMap::new());
1158    }
1159    let admission = crate::project_state::ProjectStateGuard::preflight_database_path(
1160        &config.db_path,
1161        &config.root,
1162    )
1163    .map_err(|source| AtlasMapError::Io {
1164        path: config.db_path.clone(),
1165        source,
1166    })?;
1167    let database = crate::runtime::admitted_database_path(&config.db_path, admission.as_ref());
1168    let store =
1169        AtlasStore::open_read_only_for_project(database, &config.root).map_err(|source| {
1170            AtlasMapError::Database {
1171                path: config.db_path.clone(),
1172                message: source.to_string(),
1173            }
1174        })?;
1175    let nodes = store
1176        .load_nodes()
1177        .map_err(|source| AtlasMapError::Database {
1178            path: config.db_path.clone(),
1179            message: source.to_string(),
1180        })?;
1181    Ok(nodes
1182        .into_iter()
1183        .filter(|node| node.purpose.status == projectatlas_core::PurposeStatus::Approved)
1184        .filter_map(|node| {
1185            node.purpose
1186                .purpose
1187                .map(|purpose| (node.node.path, purpose))
1188        })
1189        .collect())
1190}
1191
1192/// Lint map-owned repository inputs and return typed facts.
1193pub(crate) fn lint_map(
1194    config: &AtlasMapConfig,
1195    options: LintOptions,
1196) -> AtlasMapResult<MapLintReport> {
1197    let paths = collect_repo_paths(config)?;
1198    let nonsource = read_nonsource_file_entries(config)?;
1199    let notes = if options.strict_folders {
1200        vec![
1201            "Note: --strict-folders is deprecated; database folder purpose linting uses --purpose-level."
1202                .to_string(),
1203        ]
1204    } else {
1205        Vec::new()
1206    };
1207    let non_source = NonSourceLintReport {
1208        errors: nonsource.errors,
1209        missing: nonsource.missing,
1210        invalid: nonsource.invalid,
1211    };
1212    let untracked = options
1213        .report_untracked
1214        .then(|| build_untracked_report(config, &paths, options))
1215        .transpose()?;
1216    let ok = non_source.is_empty()
1217        && !untracked
1218            .as_ref()
1219            .is_some_and(|report| report.strict && !report.disallowed.is_empty());
1220    Ok(MapLintReport {
1221        ok,
1222        notes,
1223        non_source,
1224        untracked,
1225    })
1226}
1227
1228/// Find a default config path under the current root.
1229fn find_config_path(root: &Path) -> Option<PathBuf> {
1230    let project_config = root.join(".projectatlas").join("config.toml");
1231    if project_config.exists() {
1232        return Some(project_config);
1233    }
1234    let flat_config = root.join("projectatlas.toml");
1235    if flat_config.exists() {
1236        return Some(flat_config);
1237    }
1238    None
1239}
1240
1241/// Resolve the config file path that ignore commands should edit.
1242fn resolve_config_edit_path(
1243    config_path: Option<&Path>,
1244    project_root: &Path,
1245) -> AtlasMapResult<PathBuf> {
1246    let cwd = std::env::current_dir().map_err(|source| AtlasMapError::Io {
1247        path: PathBuf::from("."),
1248        source,
1249    })?;
1250    if let Some(path) = config_path {
1251        return Ok(if path.is_absolute() {
1252            path.to_path_buf()
1253        } else {
1254            cwd.join(path)
1255        });
1256    }
1257    Ok(find_config_path(project_root)
1258        .unwrap_or_else(|| project_root.join(".projectatlas").join("config.toml")))
1259}
1260
1261/// Load an editable TOML document, creating default config text when absent.
1262fn load_config_document_for_edit(path: &Path) -> AtlasMapResult<DocumentMut> {
1263    let text = if path.exists() {
1264        fs::read_to_string(path).map_err(|source| AtlasMapError::Io {
1265            path: path.to_path_buf(),
1266            source,
1267        })?
1268    } else {
1269        default_config_text()
1270    };
1271    text.parse::<DocumentMut>()
1272        .map_err(|source| AtlasMapError::TomlEdit {
1273            path: path.to_path_buf(),
1274            message: source.to_string(),
1275        })
1276}
1277
1278/// Persist an editable TOML document to disk.
1279fn write_config_document(
1280    path: &Path,
1281    document: &DocumentMut,
1282    project_root: &Path,
1283) -> AtlasMapResult<()> {
1284    if let Some(mut guard) =
1285        crate::project_state::ProjectStateGuard::preflight_document(project_root, path).map_err(
1286            |source| AtlasMapError::Io {
1287                path: path.to_path_buf(),
1288                source,
1289            },
1290        )?
1291    {
1292        return guard
1293            .write_document(document.to_string().as_bytes())
1294            .map_err(|source| AtlasMapError::Io {
1295                path: path.to_path_buf(),
1296                source,
1297            });
1298    }
1299    if let Some(parent) = path.parent()
1300        && !parent.as_os_str().is_empty()
1301    {
1302        fs::create_dir_all(parent).map_err(|source| AtlasMapError::Io {
1303            path: parent.to_path_buf(),
1304            source,
1305        })?;
1306    }
1307    fs::write(path, document.to_string()).map_err(|source| AtlasMapError::Io {
1308        path: path.to_path_buf(),
1309        source,
1310    })
1311}
1312
1313/// Read a string array from the `[scan]` table, returning an empty set when absent.
1314fn string_array_values(
1315    path: &Path,
1316    document: &DocumentMut,
1317    kind: IgnoreEntryKind,
1318) -> AtlasMapResult<BTreeSet<String>> {
1319    let key = kind.config_key();
1320    let Some(scan) = document.get("scan") else {
1321        return Ok(BTreeSet::new());
1322    };
1323    let Some(table) = scan.as_table() else {
1324        return Err(AtlasMapError::TomlEdit {
1325            path: path.to_path_buf(),
1326            message: "[scan] must be a TOML table".to_string(),
1327        });
1328    };
1329    let Some(item) = table.get(key) else {
1330        return Ok(BTreeSet::new());
1331    };
1332    let Some(array) = item.as_array() else {
1333        return Err(AtlasMapError::TomlEdit {
1334            path: path.to_path_buf(),
1335            message: format!("[scan].{key} must be an array of strings"),
1336        });
1337    };
1338    let mut values = BTreeSet::new();
1339    for value in array {
1340        let Some(text) = value.as_str() else {
1341            return Err(AtlasMapError::TomlEdit {
1342                path: path.to_path_buf(),
1343                message: format!("[scan].{key} must contain only strings"),
1344            });
1345        };
1346        let trimmed = text.trim();
1347        if !trimmed.is_empty() {
1348            values.insert(normalize_ignore_value(kind, trimmed)?);
1349        }
1350    }
1351    Ok(values)
1352}
1353
1354/// Replace one `[scan]` string array while preserving unrelated config content.
1355fn write_string_array(
1356    document: &mut DocumentMut,
1357    kind: IgnoreEntryKind,
1358    values: &BTreeSet<String>,
1359) -> AtlasMapResult<()> {
1360    let key = kind.config_key();
1361    if document.get("scan").is_none() {
1362        document["scan"] = Item::Table(Table::new());
1363    }
1364    let Some(scan) = document["scan"].as_table_mut() else {
1365        return Err(AtlasMapError::TomlEdit {
1366            path: PathBuf::from("<config>"),
1367            message: "[scan] must be a TOML table".to_string(),
1368        });
1369    };
1370    if scan.get(key).is_none() {
1371        scan[key] = value(Array::new());
1372    }
1373    let Some(array) = scan[key].as_array_mut() else {
1374        return Err(AtlasMapError::TomlEdit {
1375            path: PathBuf::from("<config>"),
1376            message: format!("[scan].{key} must be an array of strings"),
1377        });
1378    };
1379    let mut retained = BTreeSet::new();
1380    let mut index = 0;
1381    while index < array.len() {
1382        let Some(text) = array.get(index).and_then(toml_edit::Value::as_str) else {
1383            return Err(AtlasMapError::TomlEdit {
1384                path: PathBuf::from("<config>"),
1385                message: format!("[scan].{key} must contain only strings"),
1386            });
1387        };
1388        let trimmed = text.trim();
1389        if trimmed.is_empty() {
1390            index += 1;
1391            continue;
1392        }
1393        let normalized = normalize_ignore_value(kind, trimmed)?;
1394        if values.contains(&normalized) {
1395            retained.insert(normalized);
1396            index += 1;
1397        } else {
1398            array.remove(index);
1399        }
1400    }
1401    for missing in values.difference(&retained) {
1402        array.push(missing.as_str());
1403    }
1404    Ok(())
1405}
1406
1407/// Normalize raw config into runtime config.
1408fn normalize_config(
1409    raw: RawConfig,
1410    config_path: Option<&Path>,
1411    base_dir: &Path,
1412    cwd: &Path,
1413) -> AtlasMapResult<AtlasMapConfig> {
1414    let project = raw.project.unwrap_or_default();
1415    let private_config = if project
1416        .root
1417        .as_deref()
1418        .is_none_or(|root| root.trim() == ".")
1419    {
1420        config_path_is_projectatlas(config_path)?
1421    } else {
1422        false
1423    };
1424    let root = match project.root {
1425        Some(root) if root.trim() == "." && private_config => {
1426            project_root_for_projectatlas_config(config_path, cwd)
1427        }
1428        Some(root) => absolutize(base_dir, &root),
1429        None if private_config => project_root_for_projectatlas_config(config_path, cwd),
1430        None => cwd.to_path_buf(),
1431    };
1432    let root = root.canonicalize().map_err(|source| AtlasMapError::Io {
1433        path: root.clone(),
1434        source,
1435    })?;
1436    let scan = raw.scan.unwrap_or_default();
1437    let purpose = raw.purpose.unwrap_or_default();
1438    let summary = raw.summary_rules.unwrap_or_default();
1439    let untracked = raw.untracked.unwrap_or_default();
1440    Ok(AtlasMapConfig {
1441        map_path: absolutize(
1442            &root,
1443            project.map_path.as_deref().unwrap_or(DEFAULT_MAP_PATH),
1444        ),
1445        nonsource_files_path: absolutize(
1446            &root,
1447            project
1448                .nonsource_files_path
1449                .as_deref()
1450                .or(project.manual_files_path.as_deref())
1451                .unwrap_or(DEFAULT_NONSOURCE_PATH),
1452        ),
1453        purpose_filename: project
1454            .purpose_filename
1455            .unwrap_or_else(|| DEFAULT_LEGACY_PURPOSE_FILENAME.to_string()),
1456        source_extensions: normalize_set(
1457            scan.source_extensions
1458                .filter(|extensions| {
1459                    !extensions
1460                        .iter()
1461                        .map(String::as_str)
1462                        .eq(DEFAULT_SOURCE_EXTENSIONS.iter().copied())
1463                })
1464                .unwrap_or_else(default_source_extensions),
1465        ),
1466        exclude_dir_names: exclude_dir_name_set(scan.exclude_dir_names),
1467        exclude_dir_suffixes: string_set(scan.exclude_dir_suffixes, &[".egg-info"]),
1468        exclude_path_prefixes: normalize_prefix_set(scan.exclude_path_prefixes)?,
1469        non_source_path_prefixes: normalize_prefix_set(scan.non_source_path_prefixes)?,
1470        language_overrides: normalize_language_overrides(scan.language_overrides)?,
1471        allowed_untracked_filenames: string_set(untracked.allowed_filenames, &[]),
1472        untracked_allowlist_dir_prefixes: normalize_prefix_set(untracked.allowlist_dir_prefixes)?,
1473        untracked_allowlist_files: normalize_prefix_set(untracked.allowlist_files)?,
1474        asset_allowed_prefixes: normalize_prefix_set(untracked.asset_allowed_prefixes)?,
1475        asset_extensions: normalize_set(untracked.asset_extensions.unwrap_or_else(|| {
1476            DEFAULT_ASSET_EXTENSIONS
1477                .iter()
1478                .map(ToString::to_string)
1479                .collect()
1480        })),
1481        db_path: root.join(".projectatlas").join("projectatlas.db"),
1482        max_scan_lines: scan.max_scan_lines.unwrap_or(DEFAULT_MAX_SCAN_LINES),
1483        text_index_max_bytes: scan
1484            .text_index_max_bytes
1485            .filter(|value| *value > 0)
1486            .unwrap_or(DEFAULT_TEXT_INDEX_MAX_BYTES),
1487        summary_max_length: summary.max_length.unwrap_or(DEFAULT_SUMMARY_MAX_LENGTH),
1488        summary_ascii_only: summary.ascii_only.unwrap_or(true),
1489        summary_no_commas: summary.no_commas.unwrap_or(true),
1490        purpose_styles: normalize_style_map(purpose.styles_by_extension),
1491        purpose_default_style: purpose
1492            .default_style
1493            .unwrap_or_else(|| "javadoc".to_string()),
1494        line_comment_prefixes: purpose.line_comment_prefixes.unwrap_or_else(|| {
1495            DEFAULT_LINE_COMMENT_PREFIXES
1496                .iter()
1497                .map(ToString::to_string)
1498                .collect()
1499        }),
1500        root,
1501    })
1502}
1503
1504/// Return whether the config path is inside `.projectatlas`.
1505fn config_path_is_projectatlas(config_path: Option<&Path>) -> AtlasMapResult<bool> {
1506    let Some(parent) = config_path.and_then(Path::parent) else {
1507        return Ok(false);
1508    };
1509    crate::project_state::state_directory_spelling(parent).map_err(|source| AtlasMapError::Io {
1510        path: parent.to_path_buf(),
1511        source,
1512    })
1513}
1514
1515/// Return the project root implied by `.projectatlas/config.toml`.
1516fn project_root_for_projectatlas_config(config_path: Option<&Path>, cwd: &Path) -> PathBuf {
1517    let Some(root) = config_path
1518        .and_then(Path::parent)
1519        .and_then(Path::parent)
1520        .filter(|path| !path.as_os_str().is_empty() && *path != Path::new("."))
1521    else {
1522        return cwd.to_path_buf();
1523    };
1524    root.to_path_buf()
1525}
1526
1527/// Convert a possibly relative path to an absolute path.
1528fn absolutize(base: &Path, value: &str) -> PathBuf {
1529    let path = PathBuf::from(value);
1530    if path.is_absolute() {
1531        path
1532    } else {
1533        base.join(path)
1534    }
1535}
1536
1537/// Normalize extension strings into a lower-case set.
1538fn normalize_set(values: Vec<String>) -> BTreeSet<String> {
1539    values
1540        .into_iter()
1541        .map(|value| value.trim().to_ascii_lowercase())
1542        .filter(|value| !value.is_empty())
1543        .collect()
1544}
1545
1546/// Validate explicit exact-filename and extension language selections.
1547fn normalize_language_overrides(
1548    values: Option<BTreeMap<String, String>>,
1549) -> AtlasMapResult<BTreeMap<String, String>> {
1550    let mut normalized = BTreeMap::new();
1551    for (raw_selector, raw_language) in values.unwrap_or_default() {
1552        let selector = raw_selector.trim();
1553        let valid_selector = !selector.is_empty() && !selector.contains(['/', '\\']);
1554        if !valid_selector {
1555            return Err(AtlasMapError::InvalidLanguageOverride {
1556                selector: raw_selector,
1557                language: raw_language,
1558                message: "selector must be one exact filename or a dot-prefixed extension"
1559                    .to_string(),
1560            });
1561        }
1562        let Some(language) = canonical_language_id(&raw_language) else {
1563            return Err(AtlasMapError::InvalidLanguageOverride {
1564                selector: raw_selector,
1565                language: raw_language,
1566                message: "target is not an accepted canonical language ID or alias".to_string(),
1567            });
1568        };
1569        let selector = if selector.starts_with('.') {
1570            selector.to_ascii_lowercase()
1571        } else {
1572            selector.to_string()
1573        };
1574        if let Some(previous) = normalized.insert(selector.clone(), language.to_string()) {
1575            return Err(AtlasMapError::InvalidLanguageOverride {
1576                selector,
1577                language: language.to_string(),
1578                message: format!("selector collides after normalization with target {previous:?}"),
1579            });
1580        }
1581    }
1582    Ok(normalized)
1583}
1584
1585/// Normalize one manual ignore entry.
1586fn normalize_ignore_value(kind: IgnoreEntryKind, value: &str) -> AtlasMapResult<String> {
1587    match kind {
1588        IgnoreEntryKind::DirName => normalize_ignore_dir_name(value),
1589        IgnoreEntryKind::PathPrefix => {
1590            let normalized = normalize_repo_string(value)?;
1591            if normalized == "." {
1592                return Err(AtlasMapError::InvalidRepositoryPath {
1593                    path: value.to_string(),
1594                    message: "project root cannot be ignored by ProjectAtlas".to_string(),
1595                });
1596            }
1597            Ok(normalized)
1598        }
1599    }
1600}
1601
1602/// Normalize one directory-name ignore entry.
1603fn normalize_ignore_dir_name(value: &str) -> AtlasMapResult<String> {
1604    let trimmed = value.trim().trim_matches('/').trim_matches('\\');
1605    if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
1606        return Err(AtlasMapError::InvalidRepositoryPath {
1607            path: value.to_string(),
1608            message: "directory name ignore must name one directory".to_string(),
1609        });
1610    }
1611    if trimmed.contains('/') || trimmed.contains('\\') {
1612        return Err(AtlasMapError::InvalidRepositoryPath {
1613            path: value.to_string(),
1614            message:
1615                "directory-name ignores cannot contain path separators; use path-prefix instead"
1616                    .to_string(),
1617        });
1618    }
1619    Ok(trimmed.to_string())
1620}
1621
1622/// Convert optional strings into a set with defaults.
1623fn string_set(values: Option<Vec<String>>, defaults: &[&str]) -> BTreeSet<String> {
1624    values
1625        .unwrap_or_else(|| defaults.iter().map(ToString::to_string).collect())
1626        .into_iter()
1627        .filter(|value| !value.trim().is_empty())
1628        .collect()
1629}
1630
1631/// Normalize excluded directory names and preserve required internal excludes.
1632fn exclude_dir_name_set(values: Option<Vec<String>>) -> BTreeSet<String> {
1633    let mut names = string_set(values, DEFAULT_EXCLUDE_DIR_NAMES);
1634    names.extend(REQUIRED_EXCLUDE_DIR_NAMES.iter().map(ToString::to_string));
1635    names
1636}
1637
1638/// Build a report for current ignore settings.
1639fn ignore_list_report(path: &Path, config: &AtlasMapConfig) -> IgnoreListReport {
1640    let gitignore_path = config.root.join(".gitignore");
1641    IgnoreListReport {
1642        config_path: effective_config_path_display(path),
1643        gitignore_path: effective_config_path_display(&gitignore_path),
1644        gitignore_present: gitignore_path.exists(),
1645        gitignore_mode: "inherited-when-present".to_string(),
1646        manual_layer_order: "after-gitignore".to_string(),
1647        exclude_dir_names: config.exclude_dir_names.iter().cloned().collect(),
1648        exclude_path_prefixes: config.exclude_path_prefixes.iter().cloned().collect(),
1649    }
1650}
1651
1652/// Build a report for an ignore mutation.
1653fn ignore_mutation_report(
1654    path: &Path,
1655    action: &str,
1656    kind: &str,
1657    value: &str,
1658    changed: bool,
1659    config: &AtlasMapConfig,
1660) -> IgnoreMutationReport {
1661    let gitignore_path = config.root.join(".gitignore");
1662    IgnoreMutationReport {
1663        config_path: effective_config_path_display(path),
1664        gitignore_path: effective_config_path_display(&gitignore_path),
1665        gitignore_present: gitignore_path.exists(),
1666        action: action.to_string(),
1667        kind: kind.to_string(),
1668        value: value.to_string(),
1669        changed,
1670        gitignore_mode: "inherited-when-present".to_string(),
1671        manual_layer_order: "after-gitignore".to_string(),
1672        exclude_dir_names: config.exclude_dir_names.iter().cloned().collect(),
1673        exclude_path_prefixes: config.exclude_path_prefixes.iter().cloned().collect(),
1674    }
1675}
1676
1677/// Normalize path-prefix strings into slash-separated values.
1678fn normalize_prefix_set(values: Option<Vec<String>>) -> AtlasMapResult<BTreeSet<String>> {
1679    let mut prefixes = BTreeSet::new();
1680    for value in values.unwrap_or_default() {
1681        let normalized = normalize_repo_string(&value)?;
1682        if !normalized.is_empty() {
1683            prefixes.insert(normalized);
1684        }
1685    }
1686    Ok(prefixes)
1687}
1688
1689/// Normalize per-extension purpose styles.
1690fn normalize_style_map(values: Option<BTreeMap<String, String>>) -> BTreeMap<String, String> {
1691    let mut map = BTreeMap::new();
1692    map.insert(".py".to_string(), "python-docstring".to_string());
1693    map.insert(".vue".to_string(), "vue-block".to_string());
1694    map.insert(".rs".to_string(), "line-comment".to_string());
1695    map.insert(".go".to_string(), "line-comment".to_string());
1696    map.insert(".sh".to_string(), "line-comment".to_string());
1697    map.insert(".bash".to_string(), "line-comment".to_string());
1698    map.insert(".zsh".to_string(), "line-comment".to_string());
1699    map.insert(".ps1".to_string(), "line-comment".to_string());
1700    map.insert(".psm1".to_string(), "line-comment".to_string());
1701    map.insert(".psd1".to_string(), "line-comment".to_string());
1702    map.insert(".sql".to_string(), "line-comment".to_string());
1703    if let Some(values) = values {
1704        for (extension, style) in values {
1705            map.insert(extension.to_ascii_lowercase(), style);
1706        }
1707    }
1708    map
1709}
1710
1711/// Collect repository folders, source files, and non-source files.
1712fn collect_repo_paths(config: &AtlasMapConfig) -> AtlasMapResult<RepoPaths> {
1713    let options = config.scan_options();
1714    let nodes = scan_repo(&config.root, &options)?;
1715    Ok(repo_paths_from_nodes(config, &nodes))
1716}
1717
1718/// Classify one existing scan into legacy map input paths.
1719fn repo_paths_from_nodes(config: &AtlasMapConfig, nodes: &[Node]) -> RepoPaths {
1720    let mut folders = Vec::new();
1721    let mut source_files = Vec::new();
1722    let mut untracked_files = Vec::new();
1723    let mut excluded_paths = BTreeSet::new();
1724    for node in nodes {
1725        if has_excluded_suffix_component(&node.path, &config.exclude_dir_suffixes) {
1726            excluded_paths.insert(node.path.clone());
1727            continue;
1728        }
1729        match node.kind {
1730            NodeKind::Folder => {
1731                if is_legacy_map_metadata_folder(&node.path) {
1732                    continue;
1733                }
1734                folders.push(node.path.clone());
1735            }
1736            NodeKind::File => {
1737                if is_durable_projectatlas_input(&node.path, config) {
1738                    continue;
1739                }
1740                if is_source_node(
1741                    &node.path,
1742                    node.extension.as_deref(),
1743                    node.language.as_deref(),
1744                    config,
1745                ) {
1746                    source_files.push(node.path.clone());
1747                } else {
1748                    untracked_files.push(node.path.clone());
1749                }
1750            }
1751        }
1752    }
1753    folders.sort();
1754    source_files.sort();
1755    untracked_files.sort();
1756    RepoPaths {
1757        folders,
1758        source_files,
1759        untracked_files,
1760        excluded_paths: excluded_paths.into_iter().collect(),
1761    }
1762}
1763
1764/// Return whether any path component has an excluded suffix.
1765fn has_excluded_suffix_component(path: &str, suffixes: &BTreeSet<String>) -> bool {
1766    path.split('/').any(|part| {
1767        suffixes
1768            .iter()
1769            .any(|suffix| !suffix.is_empty() && part.ends_with(suffix))
1770    })
1771}
1772
1773/// Return whether a folder is `ProjectAtlas` metadata ignored by legacy map/lint.
1774fn is_legacy_map_metadata_folder(path: &str) -> bool {
1775    path == ".projectatlas"
1776}
1777
1778/// Return whether a file is a durable `ProjectAtlas` input outside legacy map/lint.
1779fn is_durable_projectatlas_input(path: &str, config: &AtlasMapConfig) -> bool {
1780    DURABLE_PROJECTATLAS_INPUT_PATHS.contains(&path)
1781        || configured_nonsource_registry_path(config).as_deref() == Some(path)
1782}
1783
1784/// Return the configured non-source registry as a repository-relative path.
1785fn configured_nonsource_registry_path(config: &AtlasMapConfig) -> Option<String> {
1786    let relative = config
1787        .nonsource_files_path
1788        .strip_prefix(&config.root)
1789        .ok()?;
1790    validated_repo_file_key(relative).ok()
1791}
1792
1793/// Return whether a scanned file should be treated as source.
1794fn is_source_node(
1795    path: &str,
1796    extension: Option<&str>,
1797    language: Option<&str>,
1798    config: &AtlasMapConfig,
1799) -> bool {
1800    if is_under_any_prefix(path, &config.non_source_path_prefixes) {
1801        return false;
1802    }
1803    extension.is_some_and(|extension| config.source_extensions.contains(extension))
1804        || is_path_special_source_family(language)
1805}
1806
1807/// Return whether the scanner detected a source-like file family without relying on extension policy.
1808fn is_path_special_source_family(language: Option<&str>) -> bool {
1809    matches!(
1810        language,
1811        Some("cargo-manifest" | "cargo-lock" | "rust-build-script" | "dockerfile" | "makefile")
1812    )
1813}
1814
1815/// Build the full atlas snapshot.
1816fn build_snapshot(config: &AtlasMapConfig) -> AtlasMapResult<AtlasSnapshot> {
1817    let paths = collect_repo_paths(config)?;
1818    let db_purposes = load_db_purpose_records(config)?;
1819    let (file_records, _, _) = build_file_records(&paths.source_files, config, &db_purposes)?;
1820    let nonsource = read_nonsource_file_entries(config)?;
1821    let merged_file_records = merge_records(&file_records, &nonsource.records);
1822    let (folder_records, _, _) = build_folder_records(&paths.folders, config, &db_purposes)?;
1823    let folder_summary_map = folder_records
1824        .iter()
1825        .map(|record| (record.path.clone(), record.summary.clone()))
1826        .collect::<BTreeMap<_, _>>();
1827    let folder_tree = build_folder_tree(&paths.folders, &folder_summary_map);
1828    let folder_duplicates = build_summary_duplicates(&folder_records);
1829    let file_duplicates = build_summary_duplicates(&merged_file_records);
1830    let file_hash = compute_file_hash(&merged_file_records);
1831    let folder_hash = compute_folder_hash(&paths.folders);
1832    let overview = compute_overview(
1833        &paths,
1834        config,
1835        nonsource
1836            .records
1837            .iter()
1838            .filter(|record| record.source == "nonsource")
1839            .count(),
1840    );
1841    Ok(AtlasSnapshot {
1842        folder_records,
1843        file_records: merged_file_records,
1844        folder_tree,
1845        folder_duplicates,
1846        file_duplicates,
1847        generated_at: generated_at(),
1848        file_hash,
1849        folder_hash,
1850        overview,
1851    })
1852}
1853
1854/// Return a simple UTC-ish generated timestamp.
1855fn generated_at() -> String {
1856    let seconds = SystemTime::now()
1857        .duration_since(UNIX_EPOCH)
1858        .map_or(0, |duration| duration.as_secs());
1859    format!("unix:{seconds}")
1860}
1861
1862/// Build file records and validation lists.
1863fn build_file_records(
1864    files: &[String],
1865    config: &AtlasMapConfig,
1866    db_purposes: &BTreeMap<String, String>,
1867) -> AtlasMapResult<(Vec<MapRecord>, Vec<String>, BTreeMap<String, Vec<String>>)> {
1868    let mut read_text = read_text_file;
1869    build_file_records_with_reader(files, config, db_purposes, &mut read_text)
1870}
1871
1872/// Build file purpose rows through a caller-owned bounded text reader.
1873fn build_file_records_with_reader<E, F>(
1874    files: &[String],
1875    config: &AtlasMapConfig,
1876    db_purposes: &BTreeMap<String, String>,
1877    read_text: &mut F,
1878) -> Result<(Vec<MapRecord>, Vec<String>, BTreeMap<String, Vec<String>>), E>
1879where
1880    E: From<AtlasMapError>,
1881    F: FnMut(&Path) -> Result<String, E>,
1882{
1883    let mut records = Vec::new();
1884    let mut missing = Vec::new();
1885    let mut invalid = BTreeMap::new();
1886    for rel_path in files {
1887        if let Some(summary) = db_purposes.get(rel_path) {
1888            records.push(MapRecord {
1889                path: rel_path.clone(),
1890                summary: summary.clone(),
1891                source: "database".to_string(),
1892            });
1893            continue;
1894        }
1895        let path = repo_join(&config.root, rel_path);
1896        let (summary, header_issues) =
1897            extract_purpose_header_with_reader(&path, rel_path, config, read_text)?;
1898        if let Some(summary) = summary {
1899            let issues = validate_summary(&summary, config);
1900            if issues.is_empty() {
1901                records.push(MapRecord {
1902                    path: rel_path.clone(),
1903                    summary,
1904                    source: "header".to_string(),
1905                });
1906            } else {
1907                invalid.insert(rel_path.clone(), issues);
1908                records.push(missing_record(rel_path));
1909            }
1910        } else if header_issues
1911            .iter()
1912            .any(|issue| issue.starts_with("missing "))
1913        {
1914            missing.push(rel_path.clone());
1915            records.push(missing_record(rel_path));
1916        } else {
1917            invalid.insert(rel_path.clone(), header_issues);
1918            records.push(invalid_record(rel_path));
1919        }
1920    }
1921    Ok((records, missing, invalid))
1922}
1923
1924/// Build folder records and validation lists.
1925fn build_folder_records(
1926    folders: &[String],
1927    config: &AtlasMapConfig,
1928    db_purposes: &BTreeMap<String, String>,
1929) -> AtlasMapResult<(Vec<MapRecord>, Vec<String>, BTreeMap<String, Vec<String>>)> {
1930    let mut read_text = read_text_file;
1931    build_folder_records_with_reader(folders, config, db_purposes, &mut read_text)
1932}
1933
1934/// Build folder purpose rows through a caller-owned bounded text reader.
1935fn build_folder_records_with_reader<E, F>(
1936    folders: &[String],
1937    config: &AtlasMapConfig,
1938    db_purposes: &BTreeMap<String, String>,
1939    read_text: &mut F,
1940) -> Result<(Vec<MapRecord>, Vec<String>, BTreeMap<String, Vec<String>>), E>
1941where
1942    E: From<AtlasMapError>,
1943    F: FnMut(&Path) -> Result<String, E>,
1944{
1945    let mut records = Vec::new();
1946    let mut missing = Vec::new();
1947    let mut invalid = BTreeMap::new();
1948    for folder in folders {
1949        if let Some(summary) = db_purposes.get(folder) {
1950            records.push(MapRecord {
1951                path: folder.clone(),
1952                summary: summary.clone(),
1953                source: "database".to_string(),
1954            });
1955            continue;
1956        }
1957        let (summary, issues) = read_folder_purpose_with_reader(folder, config, read_text)?;
1958        if let Some(summary) = summary {
1959            if issues.is_empty() {
1960                records.push(MapRecord {
1961                    path: folder.clone(),
1962                    summary,
1963                    source: "purpose".to_string(),
1964                });
1965            } else {
1966                invalid.insert(folder.clone(), issues);
1967                records.push(invalid_record(folder));
1968            }
1969        } else if issues.iter().any(|issue| issue == "missing .purpose file") {
1970            missing.push(folder.clone());
1971            records.push(missing_record(folder));
1972        } else {
1973            invalid.insert(folder.clone(), issues);
1974            records.push(invalid_record(folder));
1975        }
1976    }
1977    Ok((records, missing, invalid))
1978}
1979
1980/// Create a missing placeholder record.
1981fn missing_record(path: &str) -> MapRecord {
1982    MapRecord {
1983        path: path.to_string(),
1984        summary: "MISSING".to_string(),
1985        source: "missing".to_string(),
1986    }
1987}
1988
1989/// Create an invalid placeholder record.
1990fn invalid_record(path: &str) -> MapRecord {
1991    MapRecord {
1992        path: path.to_string(),
1993        summary: "INVALID".to_string(),
1994        source: "invalid".to_string(),
1995    }
1996}
1997
1998/// Extract a purpose header through a caller-owned bounded text reader.
1999fn extract_purpose_header_with_reader<E, F>(
2000    path: &Path,
2001    rel_path: &str,
2002    config: &AtlasMapConfig,
2003    read_text: &mut F,
2004) -> Result<(Option<String>, Vec<String>), E>
2005where
2006    F: FnMut(&Path) -> Result<String, E>,
2007{
2008    let extension = normalized_extension(rel_path);
2009    let language =
2010        explicit_language_override(rel_path, Some(&extension), &config.language_overrides);
2011    if document_format_for_path(rel_path, language).is_some() {
2012        return Ok((
2013            None,
2014            vec!["missing database purpose for document".to_owned()],
2015        ));
2016    }
2017    let content = read_text(path)?;
2018    let lines = content.lines().map(ToString::to_string).collect::<Vec<_>>();
2019    let style = resolve_purpose_style(rel_path, config);
2020    let result = match style.as_str() {
2021        "python-docstring" => extract_python_docstring_purpose(&lines, config.max_scan_lines),
2022        "vue-block" => extract_vue_purpose(&lines, config.max_scan_lines),
2023        "javadoc" => extract_javadoc_purpose(&lines, config.max_scan_lines),
2024        "block-comment" => extract_block_comment_purpose(&lines, config.max_scan_lines),
2025        "line-comment" => extract_line_comment_purpose(
2026            &lines,
2027            config.max_scan_lines,
2028            &config.line_comment_prefixes,
2029        ),
2030        _ => (None, vec![format!("unsupported Purpose style: {style}")]),
2031    };
2032    Ok(result)
2033}
2034
2035/// Resolve configured purpose style for a relative path.
2036fn resolve_purpose_style(path: &str, config: &AtlasMapConfig) -> String {
2037    let extension = normalized_extension(path);
2038    config
2039        .purpose_styles
2040        .get(&extension)
2041        .cloned()
2042        .unwrap_or_else(|| config.purpose_default_style.clone())
2043}
2044
2045/// Extract a purpose from a Javadoc-style block.
2046fn extract_javadoc_purpose(
2047    lines: &[String],
2048    max_scan_lines: usize,
2049) -> (Option<String>, Vec<String>) {
2050    let Some(start) = first_content_line(lines) else {
2051        return (
2052            None,
2053            vec!["missing Javadoc-style Purpose header".to_string()],
2054        );
2055    };
2056    if !lines[start].trim_start().starts_with("/**") {
2057        return (
2058            None,
2059            vec!["missing Javadoc-style Purpose header".to_string()],
2060        );
2061    }
2062    let block = collect_until(lines, start, max_scan_lines, "*/");
2063    match block {
2064        Some(block) => purpose_from_lines(&block, None).map_or_else(
2065            || {
2066                (
2067                    None,
2068                    vec!["missing Purpose line in Javadoc-style header".to_string()],
2069                )
2070            },
2071            |summary| (Some(summary), Vec::new()),
2072        ),
2073        None => (None, vec!["unterminated Javadoc-style header".to_string()]),
2074    }
2075}
2076
2077/// Extract a purpose from a generic block comment.
2078fn extract_block_comment_purpose(
2079    lines: &[String],
2080    max_scan_lines: usize,
2081) -> (Option<String>, Vec<String>) {
2082    let Some(start) = first_content_line(lines) else {
2083        return (
2084            None,
2085            vec!["missing block comment Purpose header".to_string()],
2086        );
2087    };
2088    if !lines[start].trim_start().starts_with("/*") {
2089        return (
2090            None,
2091            vec!["missing block comment Purpose header".to_string()],
2092        );
2093    }
2094    let block = collect_until(lines, start, max_scan_lines, "*/");
2095    match block {
2096        Some(block) => purpose_from_lines(&block, None).map_or_else(
2097            || {
2098                (
2099                    None,
2100                    vec!["missing Purpose line in block comment header".to_string()],
2101                )
2102            },
2103            |summary| (Some(summary), Vec::new()),
2104        ),
2105        None => (None, vec!["unterminated block comment header".to_string()]),
2106    }
2107}
2108
2109/// Extract a purpose from a Python module docstring.
2110fn extract_python_docstring_purpose(
2111    lines: &[String],
2112    max_scan_lines: usize,
2113) -> (Option<String>, Vec<String>) {
2114    let Some(start) = first_python_doc_line(lines) else {
2115        return (
2116            None,
2117            vec!["missing module docstring Purpose header".to_string()],
2118        );
2119    };
2120    let trimmed = lines[start].trim_start();
2121    let delimiter = if trimmed.starts_with("\"\"\"") {
2122        "\"\"\""
2123    } else if trimmed.starts_with("'''") {
2124        "'''"
2125    } else {
2126        return (
2127            None,
2128            vec!["missing module docstring Purpose header".to_string()],
2129        );
2130    };
2131    let block = collect_python_docstring(lines, start, max_scan_lines, delimiter);
2132    match block {
2133        Some(block) => purpose_from_lines(&block, None).map_or_else(
2134            || {
2135                (
2136                    None,
2137                    vec!["missing Purpose line in module docstring".to_string()],
2138                )
2139            },
2140            |summary| (Some(summary), Vec::new()),
2141        ),
2142        None => (None, vec!["unterminated module docstring".to_string()]),
2143    }
2144}
2145
2146/// Extract a purpose from a Vue script or style block.
2147fn extract_vue_purpose(lines: &[String], max_scan_lines: usize) -> (Option<String>, Vec<String>) {
2148    for tag in ["script", "style"] {
2149        let Some(start) = lines
2150            .iter()
2151            .position(|line| line.trim_start().starts_with(&format!("<{tag}")))
2152        else {
2153            continue;
2154        };
2155        let Some(end) = lines
2156            .iter()
2157            .enumerate()
2158            .skip(start + 1)
2159            .find_map(|(index, line)| {
2160                line.trim_start()
2161                    .starts_with(&format!("</{tag}>"))
2162                    .then_some(index)
2163            })
2164        else {
2165            return (None, vec![format!("unterminated <{tag}> block")]);
2166        };
2167        return extract_javadoc_purpose(&lines[start + 1..end], max_scan_lines);
2168    }
2169    (
2170        None,
2171        vec!["missing Javadoc-style Purpose header in <script> or <style> block".to_string()],
2172    )
2173}
2174
2175/// Extract a purpose from a line-comment header.
2176fn extract_line_comment_purpose(
2177    lines: &[String],
2178    max_scan_lines: usize,
2179    prefixes: &[String],
2180) -> (Option<String>, Vec<String>) {
2181    let mut comment_lines = Vec::new();
2182    for line in skip_yaml_frontmatter(lines).iter().take(max_scan_lines) {
2183        let trimmed = line.trim();
2184        if trimmed.is_empty() {
2185            if comment_lines.is_empty() {
2186                continue;
2187            }
2188            break;
2189        }
2190        if trimmed.starts_with("#!") && comment_lines.is_empty() {
2191            continue;
2192        }
2193        if prefixes.iter().any(|prefix| trimmed.starts_with(prefix)) {
2194            comment_lines.push(trimmed.to_string());
2195            continue;
2196        }
2197        break;
2198    }
2199    if comment_lines.is_empty() {
2200        return (
2201            None,
2202            vec!["missing line-comment Purpose header".to_string()],
2203        );
2204    }
2205    purpose_from_lines(&comment_lines, Some(prefixes)).map_or_else(
2206        || {
2207            (
2208                None,
2209                vec!["missing Purpose line in line-comment header".to_string()],
2210            )
2211        },
2212        |summary| (Some(summary), Vec::new()),
2213    )
2214}
2215
2216/// Return lines after a leading YAML frontmatter block when one is present.
2217fn skip_yaml_frontmatter(lines: &[String]) -> &[String] {
2218    if lines.first().is_none_or(|line| line.trim() != "---") {
2219        return lines;
2220    }
2221    lines
2222        .iter()
2223        .enumerate()
2224        .skip(1)
2225        .find_map(|(index, line)| (line.trim() == "---").then_some(&lines[index + 1..]))
2226        .unwrap_or(lines)
2227}
2228
2229/// Return the first content line after shebangs and blanks.
2230fn first_content_line(lines: &[String]) -> Option<usize> {
2231    lines.iter().enumerate().find_map(|(index, line)| {
2232        let trimmed = line.trim();
2233        (!trimmed.is_empty() && !trimmed.starts_with("#!") && !is_php_open_tag_line(trimmed))
2234            .then_some(index)
2235    })
2236}
2237
2238/// Return the first Python docstring candidate line.
2239fn first_python_doc_line(lines: &[String]) -> Option<usize> {
2240    lines.iter().enumerate().find_map(|(index, line)| {
2241        let trimmed = line.trim();
2242        if trimmed.is_empty()
2243            || trimmed.starts_with("#!")
2244            || trimmed.starts_with('#')
2245            || trimmed.contains("coding:")
2246            || trimmed.contains("coding=")
2247        {
2248            None
2249        } else {
2250            Some(index)
2251        }
2252    })
2253}
2254
2255/// Return whether a line is only a PHP open tag or declaration before comments.
2256fn is_php_open_tag_line(trimmed: &str) -> bool {
2257    trimmed
2258        .strip_prefix("<?php")
2259        .map(str::trim)
2260        .is_some_and(|rest| rest.is_empty() || rest.starts_with("declare("))
2261}
2262
2263/// Collect lines until a marker appears.
2264fn collect_until(
2265    lines: &[String],
2266    start: usize,
2267    max_scan_lines: usize,
2268    marker: &str,
2269) -> Option<Vec<String>> {
2270    let mut block = Vec::new();
2271    for line in lines.iter().skip(start).take(max_scan_lines) {
2272        block.push(line.clone());
2273        if line.contains(marker) {
2274            return Some(block);
2275        }
2276    }
2277    None
2278}
2279
2280/// Collect a Python docstring body.
2281fn collect_python_docstring(
2282    lines: &[String],
2283    start: usize,
2284    max_scan_lines: usize,
2285    delimiter: &str,
2286) -> Option<Vec<String>> {
2287    let first = lines[start].trim_start();
2288    let after_open = first.strip_prefix(delimiter)?;
2289    if let Some((before_close, _)) = after_open.split_once(delimiter) {
2290        return Some(vec![before_close.to_string()]);
2291    }
2292    let mut block = vec![after_open.to_string()];
2293    for line in lines.iter().skip(start + 1).take(max_scan_lines) {
2294        if let Some((before_close, _)) = line.split_once(delimiter) {
2295            block.push(before_close.to_string());
2296            return Some(block);
2297        }
2298        block.push(line.clone());
2299    }
2300    None
2301}
2302
2303/// Extract a normalized purpose from comment lines.
2304fn purpose_from_lines(lines: &[String], prefixes: Option<&[String]>) -> Option<String> {
2305    lines.iter().find_map(|line| {
2306        let mut cleaned = line.trim().to_string();
2307        if let Some(prefixes) = prefixes {
2308            cleaned = strip_line_comment_prefix(&cleaned, prefixes);
2309        }
2310        cleaned = cleaned
2311            .trim_start_matches("/**")
2312            .trim_start_matches("/*")
2313            .trim_start_matches('*')
2314            .trim_end_matches("*/")
2315            .trim()
2316            .to_string();
2317        cleaned
2318            .split_once("Purpose:")
2319            .map(|(_, summary)| normalize_summary(summary))
2320    })
2321}
2322
2323/// Strip a line-comment prefix.
2324fn strip_line_comment_prefix(line: &str, prefixes: &[String]) -> String {
2325    for prefix in prefixes {
2326        if let Some(remainder) = line.strip_prefix(prefix) {
2327            return remainder.trim_start_matches('!').trim_start().to_string();
2328        }
2329    }
2330    line.to_string()
2331}
2332
2333/// Normalize a summary to a single-line value.
2334fn normalize_summary(summary: &str) -> String {
2335    summary.split_whitespace().collect::<Vec<_>>().join(" ")
2336}
2337
2338/// Validate a purpose summary.
2339fn validate_summary(summary: &str, config: &AtlasMapConfig) -> Vec<String> {
2340    let mut problems = Vec::new();
2341    if summary.is_empty() {
2342        problems.push("summary is empty".to_string());
2343    }
2344    if config.summary_no_commas && summary.contains(',') {
2345        problems.push("summary contains a comma".to_string());
2346    }
2347    if config.summary_ascii_only && !summary.is_ascii() {
2348        problems.push("summary contains non-ASCII characters".to_string());
2349    }
2350    if summary.len() > config.summary_max_length {
2351        problems.push("summary exceeds length limit".to_string());
2352    }
2353    problems
2354}
2355
2356/// Read folder purpose metadata through a caller-owned bounded text reader.
2357fn read_folder_purpose_with_reader<E, F>(
2358    folder: &str,
2359    config: &AtlasMapConfig,
2360    read_text: &mut F,
2361) -> Result<(Option<String>, Vec<String>), E>
2362where
2363    F: FnMut(&Path) -> Result<String, E>,
2364{
2365    let purpose_path = repo_join(&config.root, folder).join(&config.purpose_filename);
2366    if !purpose_path.exists() {
2367        return Ok((None, vec!["missing .purpose file".to_string()]));
2368    }
2369    let content = read_text(&purpose_path)?;
2370    for line in content.lines() {
2371        let trimmed = line.trim();
2372        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
2373            continue;
2374        }
2375        let summary = trimmed.split_once("Purpose:").map_or_else(
2376            || normalize_summary(trimmed),
2377            |(_, value)| normalize_summary(value),
2378        );
2379        let issues = validate_summary(&summary, config);
2380        return Ok((Some(summary), issues));
2381    }
2382    Ok((None, vec!["missing Purpose summary".to_string()]))
2383}
2384
2385/// Read non-source file entries.
2386fn read_nonsource_file_entries(config: &AtlasMapConfig) -> AtlasMapResult<NonsourceEntries> {
2387    let mut read_text = read_text_file;
2388    read_nonsource_file_entries_with_reader(config, &mut read_text)
2389}
2390
2391/// Read non-source purpose rows through a caller-owned bounded text reader.
2392fn read_nonsource_file_entries_with_reader<E, F>(
2393    config: &AtlasMapConfig,
2394    read_text: &mut F,
2395) -> Result<NonsourceEntries, E>
2396where
2397    E: From<AtlasMapError>,
2398    F: FnMut(&Path) -> Result<String, E>,
2399{
2400    if !config.nonsource_files_path.exists() {
2401        return Ok(NonsourceEntries {
2402            records: Vec::new(),
2403            missing: Vec::new(),
2404            invalid: BTreeMap::new(),
2405            errors: vec![format!(
2406                "non-source file list missing: {}",
2407                config.nonsource_files_path.display()
2408            )],
2409        });
2410    }
2411    let content = read_text(&config.nonsource_files_path)?;
2412    let mut in_nonsource = false;
2413    let mut records = Vec::new();
2414    let mut missing = Vec::new();
2415    let mut invalid = BTreeMap::new();
2416    for raw in content.lines() {
2417        let line = raw.trim();
2418        if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2419            continue;
2420        }
2421        if line.starts_with("nonsource_files[") || line.starts_with("manual_files[") {
2422            in_nonsource = true;
2423            continue;
2424        }
2425        if line.starts_with("folders[") || line.starts_with("files[") {
2426            in_nonsource = false;
2427            continue;
2428        }
2429        if !in_nonsource || line.starts_with('-') || line.ends_with(':') {
2430            continue;
2431        }
2432        let cells = split_record_cells(line);
2433        if cells.len() < 2 {
2434            continue;
2435        }
2436        let rel_path = match normalize_repo_string(&cells[0]) {
2437            Ok(path) => path,
2438            Err(error) => {
2439                invalid.insert(cells[0].clone(), vec![error.to_string()]);
2440                continue;
2441            }
2442        };
2443        let summary = normalize_summary(&cells[1]);
2444        if !repo_join(&config.root, &rel_path).exists() {
2445            missing.push(rel_path.clone());
2446            records.push(missing_record(&rel_path));
2447            continue;
2448        }
2449        let issues = validate_summary(&summary, config);
2450        if issues.is_empty() {
2451            records.push(MapRecord {
2452                path: rel_path,
2453                summary,
2454                source: "nonsource".to_string(),
2455            });
2456        } else {
2457            invalid.insert(rel_path.clone(), issues);
2458            records.push(invalid_record(&rel_path));
2459        }
2460    }
2461    Ok(NonsourceEntries {
2462        records,
2463        missing,
2464        invalid,
2465        errors: Vec::new(),
2466    })
2467}
2468
2469/// Merge source and non-source records.
2470fn merge_records(source: &[MapRecord], nonsource: &[MapRecord]) -> Vec<MapRecord> {
2471    let mut merged = source
2472        .iter()
2473        .map(|record| (record.path.clone(), record.clone()))
2474        .collect::<BTreeMap<_, _>>();
2475    for record in nonsource {
2476        merged
2477            .entry(record.path.clone())
2478            .or_insert_with(|| record.clone());
2479    }
2480    merged.into_values().collect()
2481}
2482
2483/// Build duplicate summary entries.
2484fn build_summary_duplicates(records: &[MapRecord]) -> Vec<String> {
2485    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2486    for record in records {
2487        if record.summary == "MISSING" || record.summary == "INVALID" {
2488            continue;
2489        }
2490        grouped
2491            .entry(&record.summary)
2492            .or_default()
2493            .push(&record.path);
2494    }
2495    grouped
2496        .into_iter()
2497        .filter_map(|(summary, mut paths)| {
2498            if paths.len() < 2 {
2499                None
2500            } else {
2501                paths.sort_unstable();
2502                Some(format!("{summary} :: {}", paths.join(" | ")))
2503            }
2504        })
2505        .collect()
2506}
2507
2508/// Build folder tree lines.
2509fn build_folder_tree(folders: &[String], summaries: &BTreeMap<String, String>) -> Vec<String> {
2510    folders
2511        .iter()
2512        .map(|folder| {
2513            let summary = summaries
2514                .get(folder)
2515                .map_or("MISSING", std::string::String::as_str);
2516            if folder == "." {
2517                format!(". - {summary}")
2518            } else {
2519                let depth = folder.matches('/').count();
2520                let name = folder.rsplit('/').next().unwrap_or(folder);
2521                format!("{}{name}/ - {summary}", "  ".repeat(depth))
2522            }
2523        })
2524        .collect()
2525}
2526
2527/// Compute overview counters.
2528fn compute_overview(
2529    paths: &RepoPaths,
2530    config: &AtlasMapConfig,
2531    nonsource_count: usize,
2532) -> BTreeMap<String, usize> {
2533    let mut overview = BTreeMap::new();
2534    overview.insert("tracked_source_files".to_string(), paths.source_files.len());
2535    overview.insert("tracked_nonsource_files".to_string(), nonsource_count);
2536    overview.insert(
2537        "tracked_files_total".to_string(),
2538        paths.source_files.len() + nonsource_count,
2539    );
2540    overview.insert("tracked_folders".to_string(), paths.folders.len());
2541    overview.insert(
2542        "source_extensions".to_string(),
2543        config.source_extensions.len(),
2544    );
2545    overview.insert(
2546        "exclude_dir_names".to_string(),
2547        config.exclude_dir_names.len(),
2548    );
2549    overview.insert(
2550        "exclude_path_prefixes".to_string(),
2551        config.exclude_path_prefixes.len(),
2552    );
2553    overview
2554}
2555
2556/// Compute file record hash.
2557fn compute_file_hash(records: &[MapRecord]) -> String {
2558    let payload = records
2559        .iter()
2560        .map(|record| format!("{}|{}", record.path, record.summary))
2561        .collect::<Vec<_>>()
2562        .join("\n");
2563    hash_text(&payload)
2564}
2565
2566/// Compute folder path hash.
2567fn compute_folder_hash(folders: &[String]) -> String {
2568    hash_text(&folders.join("\n"))
2569}
2570
2571/// Hash a text payload with BLAKE3.
2572fn hash_text(payload: &str) -> String {
2573    let mut hasher = Hasher::new();
2574    hasher.update(payload.as_bytes());
2575    hasher.finalize().to_hex().to_string()
2576}
2577
2578/// Render a TOON snapshot.
2579fn render_toon(snapshot: &AtlasSnapshot, config: &AtlasMapConfig) -> String {
2580    let mut lines = Vec::new();
2581    lines.push("version: 1".to_string());
2582    lines.push(format!("generated_at: {}", snapshot.generated_at));
2583    lines.push(format!("file_hash: \"{}\"", snapshot.file_hash));
2584    lines.push(format!("folder_hash: \"{}\"", snapshot.folder_hash));
2585    lines.push("root: .".to_string());
2586    lines.push(format_overview(&snapshot.overview));
2587    lines.push("source_extensions[]:".to_string());
2588    lines.extend(
2589        config
2590            .source_extensions
2591            .iter()
2592            .map(|extension| format!("  - {extension}")),
2593    );
2594    lines.push("exclude_dir_names[]:".to_string());
2595    lines.extend(
2596        config
2597            .exclude_dir_names
2598            .iter()
2599            .map(|name| format!("  - {name}")),
2600    );
2601    lines.push("exclude_path_prefixes[]:".to_string());
2602    lines.extend(
2603        config
2604            .exclude_path_prefixes
2605            .iter()
2606            .map(|prefix| format!("  - {prefix}")),
2607    );
2608    append_record_rows(&mut lines, "folders", &snapshot.folder_records);
2609    append_record_rows(&mut lines, "files", &snapshot.file_records);
2610    append_list(
2611        &mut lines,
2612        "folder_summary_duplicates",
2613        &snapshot.folder_duplicates,
2614    );
2615    append_list(
2616        &mut lines,
2617        "file_summary_duplicates",
2618        &snapshot.file_duplicates,
2619    );
2620    append_list(&mut lines, "folder_tree", &snapshot.folder_tree);
2621    lines.join("\n") + "\n"
2622}
2623
2624/// Append TOON record rows.
2625fn append_record_rows(lines: &mut Vec<String>, label: &str, records: &[MapRecord]) {
2626    lines.push(format!(
2627        "{label}[{}]{{path,summary,source}}:",
2628        records.len()
2629    ));
2630    lines.extend(records.iter().map(|record| {
2631        format!(
2632            "  {},{},{}",
2633            toon_cell(&record.path),
2634            toon_cell(&record.summary),
2635            toon_cell(&record.source)
2636        )
2637    }));
2638}
2639
2640/// Append TOON list rows.
2641fn append_list(lines: &mut Vec<String>, label: &str, entries: &[String]) {
2642    lines.push(format!("{label}[]:"));
2643    lines.extend(
2644        entries
2645            .iter()
2646            .map(|entry| format!("  - {}", toon_cell(entry))),
2647    );
2648}
2649
2650/// Render a TOON scalar cell with JSON-compatible escaping when needed.
2651fn toon_cell(value: &str) -> String {
2652    if needs_quoted_cell(value) {
2653        quote_toon_string(value)
2654    } else {
2655        value.to_string()
2656    }
2657}
2658
2659/// Return whether a tabular TOON cell needs quotes.
2660fn needs_quoted_cell(value: &str) -> bool {
2661    value.is_empty()
2662        || value.chars().any(|character| {
2663            matches!(
2664                character,
2665                ',' | '"' | '\\' | '\n' | '\r' | '\t' | '[' | ']' | '{' | '}'
2666            ) || character.is_control()
2667        })
2668        || value.trim() != value
2669}
2670
2671/// Quote a string with JSON-compatible escapes for TOON scalar cells.
2672fn quote_toon_string(value: &str) -> String {
2673    let mut quoted = String::with_capacity(value.len() + 2);
2674    quoted.push('"');
2675    for character in value.chars() {
2676        match character {
2677            '"' => quoted.push_str("\\\""),
2678            '\\' => quoted.push_str("\\\\"),
2679            '\n' => quoted.push_str("\\n"),
2680            '\r' => quoted.push_str("\\r"),
2681            '\t' => quoted.push_str("\\t"),
2682            character if character.is_control() => {
2683                push_unicode_escape_digits(&mut quoted, character as u32);
2684            }
2685            character => quoted.push(character),
2686        }
2687    }
2688    quoted.push('"');
2689    quoted
2690}
2691
2692/// Split a compact TOON record row into cells.
2693fn split_record_cells(line: &str) -> Vec<String> {
2694    let mut cells = Vec::new();
2695    let mut current = String::new();
2696    let mut chars = line.chars().peekable();
2697    let mut in_quotes = false;
2698    while let Some(character) = chars.next() {
2699        match character {
2700            '"' if in_quotes => in_quotes = false,
2701            '"' if current.trim().is_empty() => in_quotes = true,
2702            '\\' if in_quotes => push_escaped_char(&mut current, &mut chars),
2703            ',' if !in_quotes => {
2704                cells.push(current.trim().to_string());
2705                current.clear();
2706            }
2707            character => current.push(character),
2708        }
2709    }
2710    cells.push(current.trim().to_string());
2711    cells
2712}
2713
2714/// Push one escaped character from a quoted TOON cell.
2715fn push_escaped_char(current: &mut String, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
2716    match chars.next() {
2717        Some('"') => current.push('"'),
2718        Some('\\') | None => current.push('\\'),
2719        Some('n') => current.push('\n'),
2720        Some('r') => current.push('\r'),
2721        Some('t') => current.push('\t'),
2722        Some('u') => push_unicode_escape(current, chars),
2723        Some(other) => current.push(other),
2724    }
2725}
2726
2727/// Push a four-digit Unicode escape into a quoted string.
2728fn push_unicode_escape_digits(output: &mut String, value: u32) {
2729    const HEX: &[u8; 16] = b"0123456789abcdef";
2730    output.push_str("\\u");
2731    for shift in [12, 8, 4, 0] {
2732        let index = ((value >> shift) & 0x0f) as usize;
2733        output.push(char::from(HEX[index]));
2734    }
2735}
2736
2737/// Push a four-digit Unicode escape when present.
2738fn push_unicode_escape(current: &mut String, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
2739    let mut digits = String::with_capacity(4);
2740    for _ in 0..4 {
2741        if let Some(digit) = chars.next() {
2742            digits.push(digit);
2743        }
2744    }
2745    if let Ok(value) = u32::from_str_radix(&digits, 16)
2746        && let Some(character) = char::from_u32(value)
2747    {
2748        current.push(character);
2749        return;
2750    }
2751    current.push_str("\\u");
2752    current.push_str(&digits);
2753}
2754
2755/// Format overview counters.
2756fn format_overview(overview: &BTreeMap<String, usize>) -> String {
2757    let parts = OVERVIEW_KEYS
2758        .iter()
2759        .filter_map(|key| overview.get(*key).map(|value| format!("{key}={value}")))
2760        .collect::<Vec<_>>();
2761    format!("overview: {}", parts.join(" "))
2762}
2763
2764/// Render the JSON map payload.
2765fn render_json_map(snapshot: &AtlasSnapshot) -> AtlasMapResult<String> {
2766    let payload = serde_json::json!({
2767        "version": 1,
2768        "generated_at": snapshot.generated_at,
2769        "file_hash": snapshot.file_hash,
2770        "folder_hash": snapshot.folder_hash,
2771        "root": ".",
2772        "overview": snapshot.overview,
2773        "folders": snapshot.folder_records.iter().map(record_json).collect::<Vec<_>>(),
2774        "files": snapshot.file_records.iter().map(record_json).collect::<Vec<_>>(),
2775        "folder_summary_duplicates": snapshot.folder_duplicates,
2776        "file_summary_duplicates": snapshot.file_duplicates,
2777        "folder_tree": snapshot.folder_tree,
2778    });
2779    Ok(serde_json::to_string_pretty(&payload)? + "\n")
2780}
2781
2782/// Write an explicitly requested JSON map next to the legacy import location.
2783pub(crate) fn write_json_map(content: &str, config: &AtlasMapConfig) -> AtlasMapResult<()> {
2784    let json_path = match config
2785        .map_path
2786        .extension()
2787        .and_then(|extension| extension.to_str())
2788    {
2789        Some(extension) if extension.eq_ignore_ascii_case("json") => {
2790            config.map_path.with_extension(format!("{extension}.json"))
2791        }
2792        _ => config.map_path.with_extension("json"),
2793    };
2794    let document_guard =
2795        crate::project_state::ProjectStateGuard::preflight_document(&config.root, &json_path)
2796            .map_err(|source| AtlasMapError::Io {
2797                path: json_path.clone(),
2798                source,
2799            })?;
2800    if document_guard.is_none()
2801        && let Some(parent) = json_path.parent()
2802    {
2803        fs::create_dir_all(parent).map_err(|source| AtlasMapError::Io {
2804            path: parent.to_path_buf(),
2805            source,
2806        })?;
2807    }
2808    let legacy = match same_file::Handle::from_path(&config.map_path) {
2809        Ok(handle) => Some(handle),
2810        Err(source) if source.kind() == io::ErrorKind::NotFound => {
2811            match fs::symlink_metadata(&config.map_path) {
2812                Err(source) if source.kind() == io::ErrorKind::NotFound => None,
2813                Ok(metadata) if metadata.file_type().is_symlink() => {
2814                    return Err(AtlasMapError::Io {
2815                        path: config.map_path.clone(),
2816                        source: io::Error::new(
2817                            io::ErrorKind::InvalidInput,
2818                            "legacy map is a dangling symlink",
2819                        ),
2820                    });
2821                }
2822                Ok(_) => {
2823                    return Err(AtlasMapError::Io {
2824                        path: config.map_path.clone(),
2825                        source,
2826                    });
2827                }
2828                Err(source) => {
2829                    return Err(AtlasMapError::Io {
2830                        path: config.map_path.clone(),
2831                        source,
2832                    });
2833                }
2834            }
2835        }
2836        Err(source) => {
2837            return Err(AtlasMapError::Io {
2838                path: config.map_path.clone(),
2839                source,
2840            });
2841        }
2842    };
2843    if let Some(mut guard) = document_guard {
2844        if let Some(legacy) = &legacy {
2845            let aliases_legacy = match same_file::Handle::from_path(&json_path) {
2846                Ok(identity) => &identity == legacy,
2847                Err(source) if source.kind() == io::ErrorKind::NotFound => false,
2848                Err(source) => {
2849                    return Err(AtlasMapError::Io {
2850                        path: json_path,
2851                        source,
2852                    });
2853                }
2854            };
2855            if aliases_legacy {
2856                return Err(AtlasMapError::Io {
2857                    path: json_path,
2858                    source: io::Error::new(
2859                        io::ErrorKind::InvalidInput,
2860                        "JSON sidecar aliases the legacy map",
2861                    ),
2862                });
2863            }
2864        }
2865        return guard
2866            .write_document(content.as_bytes())
2867            .map_err(|source| AtlasMapError::Io {
2868                path: json_path,
2869                source,
2870            });
2871    }
2872    let mut output = match OpenOptions::new().write(true).open(&json_path) {
2873        Ok(file) => file,
2874        Err(source) if source.kind() == io::ErrorKind::NotFound => OpenOptions::new()
2875            .write(true)
2876            .create_new(true)
2877            .open(&json_path)
2878            .map_err(|source| AtlasMapError::Io {
2879                path: json_path.clone(),
2880                source,
2881            })?,
2882        Err(source) => {
2883            return Err(AtlasMapError::Io {
2884                path: json_path,
2885                source,
2886            });
2887        }
2888    };
2889    let identity =
2890        same_file::Handle::from_file(output.try_clone().map_err(|source| AtlasMapError::Io {
2891            path: json_path.clone(),
2892            source,
2893        })?)
2894        .map_err(|source| AtlasMapError::Io {
2895            path: json_path.clone(),
2896            source,
2897        })?;
2898    if legacy.as_ref() == Some(&identity) {
2899        return Err(AtlasMapError::Io {
2900            path: json_path,
2901            source: io::Error::new(
2902                io::ErrorKind::InvalidInput,
2903                "JSON sidecar aliases the legacy map",
2904            ),
2905        });
2906    }
2907    output.set_len(0).map_err(|source| AtlasMapError::Io {
2908        path: json_path.clone(),
2909        source,
2910    })?;
2911    output
2912        .write_all(content.as_bytes())
2913        .map_err(|source| AtlasMapError::Io {
2914            path: json_path,
2915            source,
2916        })
2917}
2918
2919/// Convert a map record to JSON.
2920fn record_json(record: &MapRecord) -> serde_json::Value {
2921    serde_json::json!({
2922        "path": record.path,
2923        "summary": record.summary,
2924        "source": record.source,
2925    })
2926}
2927
2928/// Build the typed untracked-file inventory.
2929fn build_untracked_report(
2930    config: &AtlasMapConfig,
2931    paths: &RepoPaths,
2932    options: LintOptions,
2933) -> AtlasMapResult<UntrackedLintReport> {
2934    let nonsource = read_nonsource_file_entries(config)?;
2935    let nonsource_paths = nonsource
2936        .records
2937        .iter()
2938        .map(|record| record.path.as_str())
2939        .collect::<BTreeSet<_>>();
2940    let db_purposes = load_db_purpose_records(config)?;
2941    let mut allowed = Vec::new();
2942    let mut disallowed = Vec::new();
2943    let mut asset_outside_roots = Vec::new();
2944    for path in &paths.untracked_files {
2945        if nonsource_paths.contains(path.as_str())
2946            || db_purposes.contains_key(path)
2947            || is_allowed_untracked(path, config)
2948        {
2949            allowed.push(path.clone());
2950        } else if is_asset_file(path, config)
2951            && !is_under_any_prefix(path, &config.asset_allowed_prefixes)
2952        {
2953            asset_outside_roots.push(path.clone());
2954            disallowed.push(path.clone());
2955        } else {
2956            disallowed.push(path.clone());
2957        }
2958    }
2959    Ok(UntrackedLintReport {
2960        total: paths.untracked_files.len(),
2961        allowed: allowed.len(),
2962        disallowed_extension_counts: summarize_extensions(&disallowed),
2963        allowed_extension_counts: summarize_extensions(&allowed),
2964        asset_roots_present: existing_asset_roots(config).len(),
2965        assets_outside_roots: asset_outside_roots,
2966        excluded_paths_present: paths.excluded_paths.len(),
2967        strict: options.strict_untracked,
2968        disallowed,
2969    })
2970}
2971
2972/// Return whether an untracked path is allowed.
2973fn is_allowed_untracked(path: &str, config: &AtlasMapConfig) -> bool {
2974    let name = path.rsplit('/').next().unwrap_or(path);
2975    config.allowed_untracked_filenames.contains(name)
2976        || config.untracked_allowlist_files.contains(path)
2977        || is_under_any_prefix(path, &config.untracked_allowlist_dir_prefixes)
2978}
2979
2980/// Return whether a file is an asset by extension.
2981fn is_asset_file(path: &str, config: &AtlasMapConfig) -> bool {
2982    config
2983        .asset_extensions
2984        .contains(&normalized_extension(path))
2985}
2986
2987/// List existing asset roots.
2988fn existing_asset_roots(config: &AtlasMapConfig) -> Vec<String> {
2989    config
2990        .asset_allowed_prefixes
2991        .iter()
2992        .filter(|prefix| repo_join(&config.root, prefix).exists())
2993        .cloned()
2994        .collect()
2995}
2996
2997/// Summarize extensions for reporting.
2998fn summarize_extensions(paths: &[String]) -> BTreeMap<String, usize> {
2999    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
3000    for path in paths {
3001        let extension = normalized_extension(path);
3002        let key = if extension.is_empty() {
3003            "<no_ext>".to_string()
3004        } else {
3005            extension
3006        };
3007        *counts.entry(key).or_default() += 1;
3008    }
3009    counts
3010}
3011
3012/// Format deterministic extension counts as compatibility list items.
3013fn format_extension_counts(counts: &BTreeMap<String, usize>) -> String {
3014    format_list(
3015        &counts
3016            .iter()
3017            .map(|(extension, count)| format!("{extension}={count}"))
3018            .collect::<Vec<_>>(),
3019    )
3020}
3021
3022/// Format report list items.
3023fn format_list(items: &[String]) -> String {
3024    items
3025        .iter()
3026        .map(|item| format!(" - {item}"))
3027        .collect::<Vec<_>>()
3028        .join("\n")
3029}
3030
3031/// Join report sections with newlines.
3032fn join_report(report: &[String]) -> String {
3033    if report.is_empty() {
3034        String::new()
3035    } else {
3036        report.join("\n") + "\n"
3037    }
3038}
3039
3040/// Return whether a path is below any configured prefix.
3041fn is_under_any_prefix(path: &str, prefixes: &BTreeSet<String>) -> bool {
3042    prefixes
3043        .iter()
3044        .any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/")))
3045}
3046
3047/// Join a repository-relative slash path onto a root.
3048fn repo_join(root: &Path, rel_path: &str) -> PathBuf {
3049    if rel_path == "." {
3050        return root.to_path_buf();
3051    }
3052    rel_path
3053        .split('/')
3054        .fold(root.to_path_buf(), |path, part| path.join(part))
3055}
3056
3057/// Normalize a path-like string to repository slash format.
3058fn normalize_repo_string(path: &str) -> AtlasMapResult<String> {
3059    let value = path.trim();
3060    if value.is_empty() || value == "." {
3061        return Ok(".".to_string());
3062    }
3063    validated_repo_file_key(Path::new(value)).map_err(|source| {
3064        AtlasMapError::InvalidRepositoryPath {
3065            path: path.to_string(),
3066            message: source.to_string(),
3067        }
3068    })
3069}
3070
3071/// Return a normalized extension from a repository path.
3072fn normalized_extension(path: &str) -> String {
3073    if path.ends_with(".d.ts") {
3074        return ".d.ts".to_string();
3075    }
3076    let file_name = path.rsplit('/').next().unwrap_or(path);
3077    match file_name.rsplit_once('.') {
3078        Some((prefix, suffix)) if !prefix.is_empty() => format!(".{}", suffix.to_ascii_lowercase()),
3079        _ => String::new(),
3080    }
3081}
3082
3083/// Build default config text.
3084fn default_config_text() -> String {
3085    default_config_text_with_root(".")
3086}
3087
3088/// Build default config text for a config file created by init.
3089fn default_config_text_for(root: &Path, config_path: &Path) -> AtlasMapResult<String> {
3090    Ok(default_config_text_with_root(&default_config_root_value(
3091        root,
3092        config_path,
3093    )?))
3094}
3095
3096/// Return the `[project].root` value that keeps an explicit init config bound to `root`.
3097fn default_config_root_value(root: &Path, config_path: &Path) -> AtlasMapResult<String> {
3098    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
3099    if config_path_is_projectatlas(Some(config_path))? {
3100        let implied = project_root_for_projectatlas_config(Some(config_path), &canonical_root);
3101        if implied
3102            .canonicalize()
3103            .is_ok_and(|implied| implied == canonical_root)
3104        {
3105            return Ok(".".to_string());
3106        }
3107    }
3108    let Some(parent) = config_path.parent() else {
3109        return Ok(".".to_string());
3110    };
3111    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
3112    let parent = resolve_config_parent(parent);
3113    if parent == root {
3114        return Ok(".".to_string());
3115    }
3116    if let Ok(relative_parent) = parent.strip_prefix(&root) {
3117        let depth = relative_parent
3118            .components()
3119            .filter(|component| matches!(component, std::path::Component::Normal(_)))
3120            .count();
3121        if depth == 0 {
3122            Ok(".".to_string())
3123        } else {
3124            Ok(std::iter::repeat_n("..", depth)
3125                .collect::<Vec<_>>()
3126                .join("/"))
3127        }
3128    } else {
3129        projectatlas_core::lossless_native_path_display(&root).map_err(|_error| {
3130            AtlasMapError::InvalidRepositoryPath {
3131                path: "<native project root>".to_string(),
3132                message: "cannot serialize a non-UTF-8 root into an external TOML config"
3133                    .to_string(),
3134            }
3135        })
3136    }
3137}
3138
3139/// Resolve a config parent through existing symlinks while preserving missing native components.
3140fn resolve_config_parent(parent: &Path) -> PathBuf {
3141    let mut missing_components = Vec::new();
3142    let mut candidate = parent;
3143    loop {
3144        match candidate.canonicalize() {
3145            Ok(mut resolved) => {
3146                for component in missing_components.iter().rev() {
3147                    resolved.push(component);
3148                }
3149                return resolved;
3150            }
3151            Err(source) if source.kind() == io::ErrorKind::NotFound => {
3152                let Some(component) = candidate.file_name() else {
3153                    return parent.to_path_buf();
3154                };
3155                missing_components.push(component.to_os_string());
3156                let Some(next) = candidate.parent() else {
3157                    return parent.to_path_buf();
3158                };
3159                candidate = next;
3160            }
3161            Err(_) => return parent.to_path_buf(),
3162        }
3163    }
3164}
3165
3166/// Build default config text with the supplied project root value.
3167fn default_config_text_with_root(root_value: &str) -> String {
3168    let default_source_extensions = default_source_extensions();
3169    let source_extension_refs = default_source_extensions
3170        .iter()
3171        .map(String::as_str)
3172        .collect::<Vec<_>>();
3173    let source_extensions = toml_array(&source_extension_refs);
3174    [
3175        "[project]",
3176        &format!(
3177            "root = \"{}\"",
3178            root_value.replace('\\', "\\\\").replace('"', "\\\"")
3179        ),
3180        "map_path = \".projectatlas/projectatlas.toon\"",
3181        "nonsource_files_path = \".projectatlas/projectatlas-nonsource-files.toon\"",
3182        "",
3183        "[scan]",
3184        &format!("source_extensions = {source_extensions}"),
3185        "exclude_dir_names = [\".git\", \".projectatlas\", \".venv\", \"__pycache__\", \"node_modules\", \"dist\", \"build\", \"target\"]",
3186        "exclude_dir_suffixes = [\".egg-info\"]",
3187        "exclude_path_prefixes = []",
3188        "non_source_path_prefixes = []",
3189        "max_scan_lines = 80",
3190        &format!("text_index_max_bytes = {DEFAULT_TEXT_INDEX_MAX_BYTES}"),
3191        "",
3192        "[scan.language_overrides]",
3193        "",
3194        "[purpose]",
3195        "default_style = \"line-comment\"",
3196        "line_comment_prefixes = [\"//\", \"#\", \"--\", \";\"]",
3197        "",
3198        "[purpose.styles_by_extension]",
3199        "\".rs\" = \"line-comment\"",
3200        "",
3201        "[summary_rules]",
3202        "ascii_only = true",
3203        "no_commas = true",
3204        "max_length = 140",
3205        "",
3206        "[untracked]",
3207        "allowed_filenames = []",
3208        "allowlist_dir_prefixes = [\".githooks\"]",
3209        "allowlist_files = []",
3210        "asset_allowed_prefixes = []",
3211        "asset_extensions = [\".png\", \".jpg\", \".jpeg\", \".svg\", \".gif\", \".webp\", \".ico\", \".pdf\"]",
3212        "",
3213    ]
3214    .join("\n")
3215}
3216
3217/// Return the normal source-extension defaults plus supported document files.
3218fn default_source_extensions() -> Vec<String> {
3219    DEFAULT_SOURCE_EXTENSIONS
3220        .iter()
3221        .chain(DOCUMENT_SOURCE_EXTENSIONS)
3222        .map(ToString::to_string)
3223        .collect()
3224}
3225
3226/// Default `.gitignore` text created only by the explicit setup helper.
3227fn default_gitignore_text() -> String {
3228    [
3229        "# ProjectAtlas local runtime state",
3230        ".projectatlas/*.db",
3231        ".projectatlas/*.db-*",
3232        ".projectatlas/*.lock",
3233        ".projectatlas/graph-stage-*/",
3234        ".projectatlas/optional-parser-pack.json",
3235        ".projectatlas/projectatlas.toon",
3236        ".projectatlas/projectatlas-purpose-review.json",
3237        ".projectatlas/projectatlas.mcp.json",
3238        ".projectatlas/projectatlas.claude.mcp.json",
3239        ".projectatlas/projectatlas.opencode.json",
3240        "",
3241    ]
3242    .join("\n")
3243}
3244
3245/// Render string values as a TOML array.
3246fn toml_array(values: &[&str]) -> String {
3247    let items = values
3248        .iter()
3249        .map(|value| format!("\"{value}\""))
3250        .collect::<Vec<_>>()
3251        .join(", ");
3252    format!("[{items}]")
3253}
3254
3255impl From<serde_json::Error> for AtlasMapError {
3256    fn from(source: serde_json::Error) -> Self {
3257        Self::Io {
3258            path: PathBuf::from("<json>"),
3259            source: std::io::Error::other(source),
3260        }
3261    }
3262}
3263
3264#[cfg(test)]
3265mod tests {
3266    use super::{
3267        AtlasMapConfig, AtlasMapError, DEFAULT_TEXT_INDEX_MAX_BYTES, IgnoreEntryKind, MapRecord,
3268        add_ignore_entry, append_existing_map_purpose_records, append_record_rows, build_snapshot,
3269        collect_repo_paths, default_config_root_value, enforce_map_response_limit,
3270        exclude_dir_name_set, extract_block_comment_purpose, extract_line_comment_purpose,
3271        load_atlas_config_from_text, normalize_repo_string, project_root_for_projectatlas_config,
3272        remove_ignore_entry, render_map_with_limit, split_record_cells, toon_cell, write_json_map,
3273    };
3274    use std::collections::{BTreeMap, BTreeSet};
3275    use std::error::Error;
3276    use std::fs;
3277    use std::io;
3278    #[cfg(unix)]
3279    #[test]
3280    fn initialization_defaults_preserve_ordinary_creation_and_existing_modes()
3281    -> Result<(), Box<dyn Error>> {
3282        use std::os::unix::fs::PermissionsExt;
3283        for relative in [
3284            None,
3285            Some(".projectatlas/config.toml"),
3286            Some("projectatlas.toml"),
3287        ] {
3288            let temp = tempfile::tempdir()?;
3289            let root = temp.path().canonicalize()?;
3290            let selected = relative.map(|relative| root.join(relative));
3291            let control = root.join("ordinary-control");
3292            fs::write(&control, b"ordinary creation control")?;
3293            let mode = fs::metadata(&control)?.permissions().mode() & 0o777;
3294            super::init_project_with_config(&root, selected.as_deref())?;
3295            let config = selected.unwrap_or_else(|| root.join(".projectatlas/config.toml"));
3296            let nonsource = root.join(".projectatlas/projectatlas-nonsource-files.toon");
3297            for path in [&config, &nonsource] {
3298                if fs::metadata(path)?.permissions().mode() & 0o777 != mode {
3299                    return Err(io::Error::other(format!(
3300                        "initialization changed ordinary creation mode for {}",
3301                        path.display()
3302                    ))
3303                    .into());
3304                }
3305                fs::set_permissions(path, fs::Permissions::from_mode(0o640))?;
3306            }
3307            let contents = [fs::read(&config)?, fs::read(&nonsource)?];
3308            super::init_project_with_config(&root, Some(&config))?;
3309            for (path, bytes) in [&config, &nonsource].into_iter().zip(contents) {
3310                if fs::metadata(path)?.permissions().mode() & 0o777 != 0o640
3311                    || fs::read(path)? != bytes
3312                {
3313                    return Err(
3314                        io::Error::other("reinitialization changed existing defaults").into(),
3315                    );
3316                }
3317            }
3318        }
3319        Ok(())
3320    }
3321
3322    #[cfg(any(unix, windows))]
3323    #[test]
3324    fn initialization_preserves_custom_config_aliases_independently_of_parent()
3325    -> Result<(), Box<dyn Error>> {
3326        let mut failures = Vec::new();
3327        for relative in [
3328            "custom.toml",
3329            ".projectatlas/custom.toml",
3330            ".projectatlas/operator/config.toml",
3331            "operator/config.toml",
3332        ] {
3333            for alias in [false, true] {
3334                let temp = tempfile::tempdir()?;
3335                let root = temp.path().join("checkout");
3336                let selected = root.join(relative);
3337                let parent = selected
3338                    .parent()
3339                    .ok_or_else(|| io::Error::other("custom config has no parent"))?;
3340                fs::create_dir_all(parent)?;
3341                let outside = temp.path().join("selected-target.toml");
3342                let target = if alias {
3343                    symlink_file(&outside, &selected)?;
3344                    &outside
3345                } else {
3346                    &selected
3347                };
3348                let control = target.with_file_name("ordinary-control");
3349                fs::write(&control, b"ordinary creation control")?;
3350                if let Err(error) = super::init_project_with_config(&root, Some(&selected)) {
3351                    failures.push(format!("{relative}, alias={alias}: {error}"));
3352                    continue;
3353                }
3354                let original = fs::read(target)?;
3355                if original.is_empty()
3356                    || fs::symlink_metadata(&selected)?.file_type().is_symlink() != alias
3357                {
3358                    return Err(io::Error::other(
3359                        "custom config did not retain its selected entry",
3360                    )
3361                    .into());
3362                }
3363                #[cfg(unix)]
3364                {
3365                    use std::os::unix::fs::PermissionsExt;
3366                    if fs::metadata(target)?.permissions().mode() & 0o777
3367                        != fs::metadata(&control)?.permissions().mode() & 0o777
3368                    {
3369                        return Err(io::Error::other(
3370                            "custom config lost ordinary creation permissions",
3371                        )
3372                        .into());
3373                    }
3374                }
3375                let permissions = fs::metadata(target)?.permissions();
3376                super::init_project_with_config(&root, Some(&selected))?;
3377                if fs::read(target)? != original
3378                    || fs::metadata(target)?.permissions() != permissions
3379                {
3380                    return Err(io::Error::other(
3381                        "reinitialization changed an existing custom config",
3382                    )
3383                    .into());
3384                }
3385            }
3386        }
3387        if failures.is_empty() {
3388            Ok(())
3389        } else {
3390            Err(io::Error::other(format!(
3391                "custom config routing depended on its parent: {failures:?}"
3392            ))
3393            .into())
3394        }
3395    }
3396
3397    #[cfg(any(unix, windows))]
3398    #[test]
3399    fn initialization_admits_explicit_managed_legacy_config_before_outputs()
3400    -> Result<(), Box<dyn Error>> {
3401        let temp = tempfile::tempdir()?;
3402        let root = temp.path().join("checkout");
3403        fs::create_dir(&root)?;
3404        let outside = temp.path().join("outside");
3405        let selected = root.join("projectatlas.toml");
3406        symlink_file(&outside, &selected)?;
3407        let result = super::init_project_with_config(&root, Some(&selected));
3408        if result.is_ok() || outside.exists() || root.join(".projectatlas").exists() {
3409            return Err(io::Error::other(
3410                "legacy config creation followed its alias or created other outputs before admission",
3411            ).into());
3412        }
3413        Ok(())
3414    }
3415
3416    #[test]
3417    fn default_map_parent_reduction_never_becomes_an_external_write() -> Result<(), Box<dyn Error>>
3418    {
3419        for existing_parent in [false, true] {
3420            let temp = tempfile::tempdir()?;
3421            let root = temp.path().join("checkout");
3422            let state = root.join(".projectatlas");
3423            fs::create_dir_all(&state)?;
3424            let missing = state.join("intermediate");
3425            if existing_parent {
3426                fs::create_dir(&missing)?;
3427            }
3428            let outside = temp.path().join("outside");
3429            fs::write(&outside, b"outside map bytes")?;
3430            let sidecar = state.join("projectatlas.json");
3431            fs::hard_link(&outside, &sidecar)?;
3432            let mut config = test_config(missing.join("../projectatlas.toon"));
3433            config.root = root.canonicalize()?;
3434            let result = write_json_map("{}", &config);
3435            if result.is_ok() != existing_parent
3436                || fs::read(&outside)? != b"outside map bytes"
3437                || missing.exists() != existing_parent
3438                || (existing_parent && fs::read(&sidecar)? != b"{}")
3439            {
3440                return Err(io::Error::other(format!(
3441                    "default map parent admission failed, existing={existing_parent}: {result:?}",
3442                ))
3443                .into());
3444            }
3445        }
3446        Ok(())
3447    }
3448
3449    #[cfg(unix)]
3450    #[test]
3451    fn automatic_document_mutations_preserve_alias_owners() -> Result<(), Box<dyn Error>> {
3452        for operation in ["config", "gitignore", "map"] {
3453            let temp = tempfile::tempdir()?;
3454            let root = temp.path().join("checkout");
3455            fs::create_dir(&root)?;
3456            let root = root.canonicalize()?;
3457            fs::create_dir(root.join(".projectatlas"))?;
3458            let outside = temp.path().join("outside");
3459            let leaf = match operation {
3460                "config" => root.join(".projectatlas/config.toml"),
3461                "gitignore" => root.join(".gitignore"),
3462                _ => root.join(".projectatlas/projectatlas.json"),
3463            };
3464            std::os::unix::fs::symlink(&outside, &leaf)?;
3465            match operation {
3466                "config" => {
3467                    if add_ignore_entry(None, &root, IgnoreEntryKind::PathPrefix, "generated")
3468                        .is_ok()
3469                    {
3470                        return Err(
3471                            io::Error::other("automatic config followed a dangling alias").into(),
3472                        );
3473                    }
3474                }
3475                "gitignore" => {
3476                    let report = super::init_gitignore(None, &root)?;
3477                    if report.created || !report.existed {
3478                        return Err(io::Error::other(
3479                            "existing gitignore alias was treated as absent",
3480                        )
3481                        .into());
3482                    }
3483                }
3484                _ => {
3485                    let mut config = test_config(root.join(".projectatlas/projectatlas.toon"));
3486                    config.root = root.clone();
3487                    if write_json_map("{}", &config).is_ok() {
3488                        return Err(
3489                            io::Error::other("automatic JSON sidecar followed an alias").into()
3490                        );
3491                    }
3492                }
3493            }
3494            if outside.exists() || !fs::symlink_metadata(&leaf)?.file_type().is_symlink() {
3495                return Err(io::Error::other(
3496                    "refusal changed an outside target or existing alias",
3497                )
3498                .into());
3499            }
3500        }
3501        Ok(())
3502    }
3503
3504    #[test]
3505    fn automatic_document_replacement_preserves_hardlinked_targets() -> Result<(), Box<dyn Error>> {
3506        for operation in ["config", "map"] {
3507            let temp = tempfile::tempdir()?;
3508            let root = temp.path().join("checkout");
3509            fs::create_dir(&root)?;
3510            let root = root.canonicalize()?;
3511            fs::create_dir(root.join(".projectatlas"))?;
3512            let outside = temp.path().join("outside");
3513            let original = if operation == "config" {
3514                super::default_config_text()
3515            } else {
3516                "outside data".to_string()
3517            };
3518            fs::write(&outside, original.as_bytes())?;
3519            let leaf = root.join(if operation == "config" {
3520                ".projectatlas/config.toml"
3521            } else {
3522                ".projectatlas/projectatlas.json"
3523            });
3524            fs::hard_link(&outside, &leaf)?;
3525            if operation == "config" {
3526                add_ignore_entry(None, &root, IgnoreEntryKind::PathPrefix, "generated")?;
3527            } else {
3528                let mut config = test_config(root.join(".projectatlas/projectatlas.toon"));
3529                config.root = root;
3530                write_json_map("{}", &config)?;
3531            }
3532            if fs::read(&outside)? != original.as_bytes() || fs::read(&leaf)? == original.as_bytes()
3533            {
3534                return Err(io::Error::other(
3535                    "document mutation changed its outside hardlink owner",
3536                )
3537                .into());
3538            }
3539        }
3540        Ok(())
3541    }
3542
3543    #[cfg(unix)]
3544    #[test]
3545    fn initialization_refuses_indirect_state_before_creating_outputs() -> Result<(), Box<dyn Error>>
3546    {
3547        let temp = tempfile::tempdir()?;
3548        let root = temp.path().join("checkout");
3549        let outside = temp.path().join("outside");
3550        fs::create_dir(&root)?;
3551        fs::create_dir(&outside)?;
3552        std::os::unix::fs::symlink(&outside, root.join(".projectatlas"))?;
3553        let result = super::init_project_with_config(&root, None);
3554        if result.is_ok() || fs::read_dir(&outside)?.next().is_some() {
3555            return Err(
3556                io::Error::other("indirect automatic state was admitted or written").into(),
3557            );
3558        }
3559        Ok(())
3560    }
3561
3562    #[cfg(unix)]
3563    use std::os::unix::fs::symlink as symlink_file;
3564    #[cfg(windows)]
3565    use std::os::windows::fs::symlink_file;
3566    use std::path::Path;
3567
3568    fn test_config(map_path: std::path::PathBuf) -> AtlasMapConfig {
3569        let root = map_path.parent().map_or_else(
3570            || std::path::PathBuf::from("."),
3571            std::path::Path::to_path_buf,
3572        );
3573        AtlasMapConfig {
3574            root: root.clone(),
3575            map_path,
3576            nonsource_files_path: root.join("projectatlas-nonsource-files.toon"),
3577            purpose_filename: ".purpose".to_string(),
3578            source_extensions: BTreeSet::new(),
3579            exclude_dir_names: BTreeSet::new(),
3580            exclude_dir_suffixes: BTreeSet::new(),
3581            exclude_path_prefixes: BTreeSet::new(),
3582            non_source_path_prefixes: BTreeSet::new(),
3583            language_overrides: BTreeMap::new(),
3584            allowed_untracked_filenames: BTreeSet::new(),
3585            untracked_allowlist_dir_prefixes: BTreeSet::new(),
3586            untracked_allowlist_files: BTreeSet::new(),
3587            asset_allowed_prefixes: BTreeSet::new(),
3588            asset_extensions: BTreeSet::new(),
3589            db_path: root.join("projectatlas.db"),
3590            max_scan_lines: 80,
3591            text_index_max_bytes: DEFAULT_TEXT_INDEX_MAX_BYTES,
3592            summary_max_length: 140,
3593            summary_ascii_only: true,
3594            summary_no_commas: true,
3595            purpose_styles: BTreeMap::new(),
3596            purpose_default_style: "line-comment".to_string(),
3597            line_comment_prefixes: vec!["//".to_string()],
3598        }
3599    }
3600
3601    #[test]
3602    fn json_sidecar_never_aliases_custom_legacy_json_path() -> Result<(), Box<dyn Error>> {
3603        for (name, sidecar) in [
3604            ("legacy.json", "legacy.json.json"),
3605            ("legacy.JSON", "legacy.JSON.json"),
3606            ("legacy.toon", "legacy.json"),
3607        ] {
3608            let temp = tempfile::tempdir()?;
3609            let legacy = temp.path().join(name);
3610            fs::write(&legacy, "legacy purpose input")?;
3611            write_json_map("{\"files\":[]}", &test_config(legacy.clone()))?;
3612            if fs::read_to_string(&legacy)? != "legacy purpose input"
3613                || fs::read_to_string(temp.path().join(sidecar))? != "{\"files\":[]}"
3614            {
3615                return Err(io::Error::other(format!(
3616                    "JSON sidecar overwrote the legacy map for {name}"
3617                ))
3618                .into());
3619            }
3620        }
3621        Ok(())
3622    }
3623
3624    #[test]
3625    fn json_sidecar_refuses_hard_link_without_changing_legacy_import() -> Result<(), Box<dyn Error>>
3626    {
3627        let temp = tempfile::tempdir()?;
3628        let legacy = temp.path().join("legacy.toon");
3629        let sidecar = temp.path().join("legacy.json");
3630        fs::write(&legacy, "legacy purpose input")?;
3631        fs::hard_link(&legacy, &sidecar)?;
3632        if !matches!(
3633            write_json_map("{\"files\":[]}", &test_config(legacy.clone())),
3634            Err(AtlasMapError::Io { source, .. }) if source.kind() == io::ErrorKind::InvalidInput
3635        ) {
3636            return Err(io::Error::other("JSON sidecar did not refuse a legacy hard link").into());
3637        }
3638        if fs::read_to_string(&legacy)? != "legacy purpose input"
3639            || fs::read_to_string(&sidecar)? != "legacy purpose input"
3640        {
3641            return Err(io::Error::other("JSON sidecar changed the hard-linked legacy map").into());
3642        }
3643        Ok(())
3644    }
3645
3646    #[cfg(any(unix, windows))]
3647    #[test]
3648    fn json_sidecar_refuses_symlink_without_changing_legacy_import() -> Result<(), Box<dyn Error>> {
3649        let temp = tempfile::tempdir()?;
3650        let legacy = temp.path().join("legacy.toon");
3651        let sidecar = temp.path().join("legacy.json");
3652        fs::write(&legacy, "legacy purpose input")?;
3653        let linked = symlink_file(&legacy, &sidecar);
3654        #[cfg(windows)]
3655        if matches!(&linked, Err(source) if source.raw_os_error() == Some(1314)) {
3656            return Ok(());
3657        }
3658        linked?;
3659        if !matches!(
3660            write_json_map("{\"files\":[]}", &test_config(legacy.clone())),
3661            Err(AtlasMapError::Io { source, .. }) if source.kind() == io::ErrorKind::InvalidInput
3662        ) {
3663            return Err(io::Error::other("JSON sidecar did not refuse a legacy symlink").into());
3664        }
3665        if fs::read_to_string(&legacy)? != "legacy purpose input"
3666            || !fs::symlink_metadata(&sidecar)?.file_type().is_symlink()
3667        {
3668            return Err(io::Error::other("JSON sidecar changed the symlinked legacy map").into());
3669        }
3670        Ok(())
3671    }
3672
3673    #[cfg(any(unix, windows))]
3674    #[test]
3675    fn json_sidecar_refuses_dangling_legacy_symlink_before_creation() -> Result<(), Box<dyn Error>>
3676    {
3677        let temp = tempfile::tempdir()?;
3678        let legacy = temp.path().join("legacy.toon");
3679        let sidecar = temp.path().join("legacy.json");
3680        let linked = symlink_file(&sidecar, &legacy);
3681        #[cfg(windows)]
3682        if matches!(&linked, Err(source) if source.raw_os_error() == Some(1314)) {
3683            return Ok(());
3684        }
3685        linked?;
3686        if !matches!(
3687            write_json_map("{\"files\":[]}", &test_config(legacy.clone())),
3688            Err(AtlasMapError::Io { source, .. }) if source.kind() == io::ErrorKind::InvalidInput
3689        ) {
3690            return Err(
3691                io::Error::other("JSON sidecar did not refuse a dangling legacy symlink").into(),
3692            );
3693        }
3694        if fs::symlink_metadata(&sidecar).is_ok() || fs::read_link(&legacy)? != sidecar {
3695            return Err(io::Error::other("JSON sidecar mutated a dangling legacy symlink").into());
3696        }
3697        Ok(())
3698    }
3699
3700    #[test]
3701    fn json_sidecar_update_preserves_existing_file_identity() -> Result<(), Box<dyn Error>> {
3702        let temp = tempfile::tempdir()?;
3703        let sidecar = temp.path().join("legacy.json");
3704        fs::write(&sidecar, "old")?;
3705        let identity = same_file::Handle::from_path(&sidecar)?;
3706        write_json_map("new", &test_config(temp.path().join("legacy.toon")))?;
3707        if same_file::Handle::from_path(&sidecar)? != identity
3708            || fs::read_to_string(&sidecar)? != "new"
3709        {
3710            return Err(io::Error::other("JSON sidecar replacement changed file identity").into());
3711        }
3712        Ok(())
3713    }
3714
3715    #[test]
3716    fn map_response_limit_refuses_complete_oversize_content() -> Result<(), Box<dyn Error>> {
3717        enforce_map_response_limit("abcd", 4)?;
3718        if !matches!(
3719            enforce_map_response_limit("abcde", 4),
3720            Err(AtlasMapError::ResponseLimitExceeded {
3721                observed: 5,
3722                limit: 4
3723            })
3724        ) {
3725            return Err(io::Error::other("map response limit did not report exact bytes").into());
3726        }
3727        let temp = tempfile::tempdir()?;
3728        let map_path = temp.path().join("projectatlas.toon");
3729        let config = test_config(map_path.clone());
3730        fs::write(
3731            temp.path().join("source.rs"),
3732            "// Purpose: Map budget fixture.\n",
3733        )?;
3734        if !matches!(
3735            render_map_with_limit(&config, false, true, 1),
3736            Err(AtlasMapError::ResponseLimitExceeded { limit: 1, .. })
3737        ) || map_path.exists()
3738            || map_path.with_extension("json").exists()
3739        {
3740            return Err(io::Error::other(
3741                "oversized map response wrote a file or lost its typed refusal",
3742            )
3743            .into());
3744        }
3745        Ok(())
3746    }
3747
3748    #[test]
3749    fn document_map_records_preserve_purposes_without_reading_binary_headers()
3750    -> Result<(), Box<dyn Error>> {
3751        let temp = tempfile::tempdir()?;
3752        let config = test_config(temp.path().join("projectatlas.toon"));
3753        for path in ["guide.pdf", "guide.DOCX", "approved.docx"] {
3754            fs::write(temp.path().join(path), b"\xff\xfe binary document")?;
3755        }
3756        fs::write(
3757            temp.path().join("lib.rs"),
3758            "// Purpose: Explain source ownership.\n",
3759        )?;
3760        let files = ["guide.pdf", "guide.DOCX", "approved.docx", "lib.rs"].map(str::to_owned);
3761        let purposes = BTreeMap::from([(
3762            "approved.docx".to_owned(),
3763            "Explain document ownership.".to_owned(),
3764        )]);
3765        let (records, missing, invalid) = super::build_file_records(&files, &config, &purposes)?;
3766        let actual = records
3767            .iter()
3768            .map(|record| {
3769                (
3770                    record.path.as_str(),
3771                    record.summary.as_str(),
3772                    record.source.as_str(),
3773                )
3774            })
3775            .collect::<Vec<_>>();
3776        if missing != ["guide.pdf", "guide.DOCX"]
3777            || !invalid.is_empty()
3778            || actual
3779                != [
3780                    ("guide.pdf", "MISSING", "missing"),
3781                    ("guide.DOCX", "MISSING", "missing"),
3782                    ("approved.docx", "Explain document ownership.", "database"),
3783                    ("lib.rs", "Explain source ownership.", "header"),
3784                ]
3785        {
3786            return Err(io::Error::other("map changed binary or text purpose ownership").into());
3787        }
3788        fs::write(temp.path().join("invalid.rs"), b"\xff")?;
3789        if super::build_file_records(&["invalid.rs".to_owned()], &config, &purposes).is_ok() {
3790            return Err(io::Error::other("map accepted invalid UTF-8 source headers").into());
3791        }
3792        Ok(())
3793    }
3794
3795    #[test]
3796    fn document_map_headers_follow_scanner_language_overrides() -> Result<(), Box<dyn Error>> {
3797        let temp = tempfile::tempdir()?;
3798        for (path, overrides, text_header) in [
3799            ("guide.pdf", vec![(".pdf", "rust")], true),
3800            ("guide.DOCX", vec![(".docx", "rust")], true),
3801            ("guide.rs", vec![(".rs", "pdf")], false),
3802            ("guide.rs", vec![("guide.rs", "docx")], false),
3803            (
3804                "guide.pdf",
3805                vec![(".pdf", "pdf"), ("guide.pdf", "rust")],
3806                true,
3807            ),
3808            (
3809                "guide.text.pdf",
3810                vec![(".pdf", "pdf"), (".text.pdf", "rust")],
3811                true,
3812            ),
3813        ] {
3814            let mut config = test_config(temp.path().join("projectatlas.toon"));
3815            config.language_overrides = overrides
3816                .into_iter()
3817                .map(|(selector, language)| (selector.to_owned(), language.to_owned()))
3818                .collect();
3819            fs::write(
3820                temp.path().join(path),
3821                if text_header {
3822                    b"// Purpose: Explain configured source ownership.\n".as_slice()
3823                } else {
3824                    b"\xff\xfe binary document"
3825                },
3826            )?;
3827            let (records, missing, invalid) =
3828                super::build_file_records(&[path.to_owned()], &config, &BTreeMap::new())?;
3829            let expected = if text_header {
3830                "Explain configured source ownership."
3831            } else {
3832                "MISSING"
3833            };
3834            if records.len() != 1
3835                || records[0].summary != expected
3836                || missing.is_empty() != text_header
3837                || !invalid.is_empty()
3838            {
3839                return Err(io::Error::other(format!(
3840                    "map ignored language override for {path}: {records:?}"
3841                ))
3842                .into());
3843            }
3844        }
3845        Ok(())
3846    }
3847
3848    #[test]
3849    fn exclude_dir_names_preserve_required_internal_excludes() {
3850        let names = exclude_dir_name_set(Some(vec!["target".to_string()]));
3851
3852        assert!(names.contains("target"));
3853        assert!(names.contains(".git"));
3854        assert!(names.contains(".projectatlas"));
3855    }
3856
3857    #[test]
3858    fn inverse_ignore_edits_restore_original_config_bytes() -> Result<(), Box<dyn Error>> {
3859        let temp = tempfile::tempdir()?;
3860        let atlas = temp.path().join(".projectatlas");
3861        fs::create_dir(&atlas)?;
3862        let config_path = atlas.join("config.toml");
3863        let original = r#"[project]
3864root = "."
3865
3866[scan]
3867# Preserve this formatting and comment.
3868exclude_dir_names = [
3869    "",
3870    "   ",
3871    ".git",
3872    ".projectatlas",
3873    "target",
3874]
3875exclude_path_prefixes = ["", "  "]
3876"#;
3877        fs::write(&config_path, original)?;
3878
3879        for (kind, value, remove_kind) in [
3880            (
3881                IgnoreEntryKind::DirName,
3882                "temporary-cache",
3883                Some(IgnoreEntryKind::DirName),
3884            ),
3885            (
3886                IgnoreEntryKind::PathPrefix,
3887                "generated/cache",
3888                Some(IgnoreEntryKind::PathPrefix),
3889            ),
3890            (IgnoreEntryKind::DirName, "temporary-untyped", None),
3891            (IgnoreEntryKind::PathPrefix, "generated/untyped", None),
3892        ] {
3893            let added = add_ignore_entry(Some(&config_path), temp.path(), kind, value)?;
3894            if !added.changed {
3895                return Err(io::Error::other("ignore add did not change the config").into());
3896            }
3897            let removed = remove_ignore_entry(Some(&config_path), temp.path(), remove_kind, value)?;
3898            if !removed.changed || fs::read_to_string(&config_path)? != original {
3899                return Err(io::Error::other(
3900                    "inverse ignore edits did not restore the original config bytes",
3901                )
3902                .into());
3903            }
3904        }
3905        for result in [
3906            add_ignore_entry(
3907                Some(&config_path),
3908                temp.path(),
3909                IgnoreEntryKind::DirName,
3910                " ",
3911            )
3912            .map(|_| ()),
3913            remove_ignore_entry(
3914                Some(&config_path),
3915                temp.path(),
3916                Some(IgnoreEntryKind::PathPrefix),
3917                "",
3918            )
3919            .map(|_| ()),
3920            remove_ignore_entry(Some(&config_path), temp.path(), None, " ").map(|_| ()),
3921        ] {
3922            if result.is_ok() || fs::read_to_string(&config_path)? != original {
3923                return Err(
3924                    io::Error::other("invalid blank ignore mutation changed the config").into(),
3925                );
3926            }
3927        }
3928        Ok(())
3929    }
3930
3931    #[test]
3932    fn config_normalizes_validated_language_overrides() -> Result<(), Box<dyn Error>> {
3933        let temp = tempfile::tempdir()?;
3934        let path = temp.path().join(".projectatlas").join("config.toml");
3935        let config = load_atlas_config_from_text(
3936            &path,
3937            r#"
3938[project]
3939root = "."
3940
3941[scan.language_overrides]
3942".D.TS" = "ts"
3943"Cargo.toml" = "py"
3944"#,
3945        )?;
3946        if config.language_overrides.get(".d.ts").map(String::as_str) != Some("typescript") {
3947            return Err(io::Error::other("compound extension override was not normalized").into());
3948        }
3949        if config
3950            .language_overrides
3951            .get("Cargo.toml")
3952            .map(String::as_str)
3953            != Some("python")
3954        {
3955            return Err(io::Error::other("exact filename override was not normalized").into());
3956        }
3957        if config.scan_options().language_overrides != config.language_overrides {
3958            return Err(io::Error::other("scanner did not receive language overrides").into());
3959        }
3960        Ok(())
3961    }
3962
3963    #[test]
3964    fn config_extends_only_the_exact_generated_source_extension_list() -> Result<(), Box<dyn Error>>
3965    {
3966        let temp = tempfile::tempdir()?;
3967        let path = temp.path().join(".projectatlas").join("config.toml");
3968        let legacy = super::DEFAULT_SOURCE_EXTENSIONS.to_vec();
3969        let mut reordered = legacy.clone();
3970        reordered.reverse();
3971        for (extensions, documents) in [
3972            (legacy, true),
3973            (vec![".rs"], false),
3974            (reordered, false),
3975            (Vec::new(), false),
3976        ] {
3977            let text = format!(
3978                "[project]\nroot = \".\"\n[scan]\nsource_extensions = {}\n",
3979                serde_json::to_string(&extensions)?
3980            );
3981            let config = load_atlas_config_from_text(&path, &text)?;
3982            if config.source_extensions.contains(".pdf") != documents
3983                || config.source_extensions.contains(".docx") != documents
3984                || (!documents
3985                    && config.source_extensions
3986                        != extensions
3987                            .iter()
3988                            .map(|value| value.to_ascii_lowercase())
3989                            .collect())
3990            {
3991                return Err(io::Error::other(format!(
3992                    "legacy defaults or custom extension policy changed incorrectly: documents={documents}, input={extensions:?}, actual={:?}", config.source_extensions,
3993                ))
3994                .into());
3995            }
3996        }
3997        Ok(())
3998    }
3999
4000    #[test]
4001    fn config_root_uses_one_canonical_database_identity() -> Result<(), Box<dyn Error>> {
4002        let temp = tempfile::tempdir()?;
4003        let nested = temp.path().join("nested");
4004        fs::create_dir(&nested)?;
4005        let config_path = nested.join("..").join(".projectatlas").join("config.toml");
4006
4007        let config = load_atlas_config_from_text(&config_path, "[project]\nroot = \".\"\n")?;
4008        let expected_root = temp.path().canonicalize()?;
4009
4010        let expected_database = expected_root.join(".projectatlas").join("projectatlas.db");
4011        if config.root != expected_root || config.db_path != expected_database {
4012            return Err(io::Error::other(format!(
4013                "config did not share one canonical database identity: {config:?}"
4014            ))
4015            .into());
4016        }
4017        Ok(())
4018    }
4019
4020    #[cfg(unix)]
4021    #[test]
4022    fn effective_config_report_does_not_replace_native_root_bytes() -> Result<(), Box<dyn Error>> {
4023        use std::ffi::OsString;
4024        use std::os::unix::ffi::OsStringExt;
4025
4026        let temp = tempfile::tempdir()?;
4027        let raw_root = temp.path().join(OsString::from_vec(b"repo-\x80".to_vec()));
4028        fs::create_dir(&raw_root)?;
4029        let config = super::load_atlas_config_for_root(&raw_root)?;
4030        let report = super::effective_config_report(&config);
4031        let value = serde_json::to_value(report)?;
4032        if value.get("root") != Some(&serde_json::Value::Null)
4033            || value.get("map_path") != Some(&serde_json::Value::Null)
4034            || value.get("db_path") != Some(&serde_json::Value::Null)
4035            || value.to_string().contains("repo-�")
4036        {
4037            return Err(io::Error::other(
4038                "effective config report exposed a lossy native root projection",
4039            )
4040            .into());
4041        }
4042        Ok(())
4043    }
4044
4045    #[test]
4046    fn config_rejects_unknown_language_override_target() {
4047        let result = load_atlas_config_from_text(
4048            Path::new(".projectatlas/config.toml"),
4049            r#"
4050[scan.language_overrides]
4051".rs" = "not-a-language"
4052"#,
4053        );
4054        assert!(matches!(
4055            result,
4056            Err(AtlasMapError::InvalidLanguageOverride { .. })
4057        ));
4058    }
4059
4060    #[test]
4061    fn toon_record_rows_escape_commas_quotes_and_newlines() {
4062        let mut lines = Vec::new();
4063        append_record_rows(
4064            &mut lines,
4065            "files",
4066            &[MapRecord {
4067                path: "docs/a,b.md".to_string(),
4068                summary: "Explain \"quoted\"\nsummary".to_string(),
4069                source: "source".to_string(),
4070            }],
4071        );
4072
4073        assert_eq!(lines[0], "files[1]{path,summary,source}:");
4074        assert_eq!(
4075            lines[1],
4076            "  \"docs/a,b.md\",\"Explain \\\"quoted\\\"\\nsummary\",source"
4077        );
4078    }
4079
4080    #[test]
4081    fn toon_record_parser_accepts_quoted_and_legacy_cells() {
4082        assert_eq!(
4083            split_record_cells("\"docs/a,b.md\",\"Summary, with comma\",source"),
4084            vec![
4085                "docs/a,b.md".to_string(),
4086                "Summary, with comma".to_string(),
4087                "source".to_string()
4088            ]
4089        );
4090        assert_eq!(
4091            split_record_cells("logo.png,Demo asset"),
4092            vec!["logo.png".to_string(), "Demo asset".to_string()]
4093        );
4094    }
4095
4096    #[test]
4097    fn simple_toon_cells_remain_unquoted() {
4098        assert_eq!(toon_cell("src/main.rs"), "src/main.rs");
4099        assert_eq!(toon_cell("Plain summary"), "Plain summary");
4100    }
4101
4102    #[test]
4103    fn line_comment_purpose_skips_yaml_frontmatter() -> Result<(), Box<dyn std::error::Error>> {
4104        let lines = [
4105            "---",
4106            "name: projectatlas",
4107            "description: Skill frontmatter must stay first.",
4108            "---",
4109            "",
4110            "# Purpose: Guide agents through ProjectAtlas workflows.",
4111            "",
4112            "# ProjectAtlas",
4113        ]
4114        .iter()
4115        .map(ToString::to_string)
4116        .collect::<Vec<_>>();
4117        let prefixes = vec!["#".to_string(), "//".to_string()];
4118
4119        let (purpose, issues) = extract_line_comment_purpose(&lines, 80, &prefixes);
4120
4121        if !issues.is_empty() {
4122            return Err(
4123                std::io::Error::other(format!("unexpected purpose issues: {issues:?}")).into(),
4124            );
4125        }
4126        if purpose.as_deref() != Some("Guide agents through ProjectAtlas workflows.") {
4127            return Err(std::io::Error::other(format!(
4128                "frontmatter purpose mismatch: {purpose:?}"
4129            ))
4130            .into());
4131        }
4132        Ok(())
4133    }
4134
4135    #[test]
4136    fn block_comment_purpose_skips_php_open_tag() -> Result<(), Box<dyn std::error::Error>> {
4137        let lines = ["<?php", "/*", "Purpose: Render legacy PHP page.", "*/"]
4138            .iter()
4139            .map(ToString::to_string)
4140            .collect::<Vec<_>>();
4141
4142        let (purpose, issues) = extract_block_comment_purpose(&lines, 80);
4143
4144        if !issues.is_empty() {
4145            return Err(
4146                std::io::Error::other(format!("unexpected purpose issues: {issues:?}")).into(),
4147            );
4148        }
4149        if purpose.as_deref() != Some("Render legacy PHP page.") {
4150            return Err(
4151                std::io::Error::other(format!("PHP block purpose mismatch: {purpose:?}")).into(),
4152            );
4153        }
4154        Ok(())
4155    }
4156
4157    #[test]
4158    fn custom_nonsource_registry_is_not_classified_as_source()
4159    -> Result<(), Box<dyn std::error::Error>> {
4160        let temp = tempfile::tempdir()?;
4161        let root = temp.path();
4162        std::fs::create_dir(root.join("src"))?;
4163        std::fs::write(
4164            root.join("src").join("live.toon"),
4165            "Purpose: Live TOON source.\n",
4166        )?;
4167        std::fs::write(
4168            root.join("src").join("projectatlas-nonsource-files.toon"),
4169            "nonsource_files[]:\n",
4170        )?;
4171        let mut config = test_config(root.join("projectatlas.toon"));
4172        config.nonsource_files_path = root.join("src").join("projectatlas-nonsource-files.toon");
4173        config.source_extensions.insert(".toon".to_string());
4174
4175        let paths = collect_repo_paths(&config)?;
4176
4177        if paths
4178            .source_files
4179            .iter()
4180            .any(|path| path == "src/projectatlas-nonsource-files.toon")
4181        {
4182            return Err(std::io::Error::other("custom non-source registry was source").into());
4183        }
4184        if !paths
4185            .source_files
4186            .iter()
4187            .any(|path| path == "src/live.toon")
4188        {
4189            return Err(std::io::Error::other("sibling TOON source was skipped").into());
4190        }
4191        Ok(())
4192    }
4193
4194    #[test]
4195    fn repo_metadata_paths_reject_parent_traversal_and_absolute_paths()
4196    -> Result<(), Box<dyn std::error::Error>> {
4197        if normalize_repo_string("../outside.txt").is_ok() {
4198            return Err(std::io::Error::other("parent traversal was accepted").into());
4199        }
4200        if normalize_repo_string("C:/outside.txt").is_ok() {
4201            return Err(std::io::Error::other("absolute Windows path was accepted").into());
4202        }
4203        let normalized = normalize_repo_string("docs\\guide.md")?;
4204        if normalized != "docs/guide.md" {
4205            return Err(
4206                std::io::Error::other(format!("normalized path mismatch: {normalized}")).into(),
4207            );
4208        }
4209        Ok(())
4210    }
4211
4212    #[test]
4213    fn bare_projectatlas_config_path_resolves_root_to_cwd() {
4214        let cwd = std::path::Path::new("repo");
4215        assert_eq!(
4216            project_root_for_projectatlas_config(
4217                Some(std::path::Path::new(".projectatlas/config.toml")),
4218                cwd,
4219            ),
4220            cwd
4221        );
4222        assert_eq!(
4223            project_root_for_projectatlas_config(
4224                Some(std::path::Path::new("./.projectatlas/config.toml")),
4225                cwd,
4226            ),
4227            cwd
4228        );
4229    }
4230
4231    #[test]
4232    fn default_init_config_root_points_from_subdir_back_to_repo()
4233    -> Result<(), Box<dyn std::error::Error>> {
4234        let temp = tempfile::tempdir()?;
4235        let root = temp.path();
4236        let config_dir = root.join("config").join("atlas");
4237        std::fs::create_dir_all(&config_dir)?;
4238
4239        let cases = [
4240            (root.join(".projectatlas").join("config.toml"), "."),
4241            (root.join("projectatlas.toml"), "."),
4242            (root.join("config").join("projectatlas.toml"), ".."),
4243            (config_dir.join("projectatlas.toml"), "../.."),
4244        ];
4245        for (config_path, expected_root) in cases {
4246            let actual_root = default_config_root_value(root, &config_path)?;
4247            if actual_root != expected_root {
4248                return Err(std::io::Error::other(format!(
4249                    "default config root mismatch for {config_path:?}: expected {expected_root}, got {actual_root}"
4250                ))
4251                .into());
4252            }
4253        }
4254        // A conventional basename in another project does not select its root.
4255        let external = tempfile::tempdir()?;
4256        let external_config = external.path().join(".projectatlas/config.toml");
4257        std::fs::create_dir_all(
4258            external_config
4259                .parent()
4260                .ok_or_else(|| std::io::Error::other("external config fixture has no parent"))?,
4261        )?;
4262        let text = super::default_config_text_for(root, &external_config)?;
4263        if load_atlas_config_from_text(&external_config, &text)?.root != root.canonicalize()? {
4264            return Err(
4265                io::Error::other("external private config changed the selected root").into(),
4266            );
4267        }
4268        Ok(())
4269    }
4270
4271    #[test]
4272    fn relative_config_roots_follow_native_private_directory_names() -> Result<(), Box<dyn Error>> {
4273        let temp = tempfile::tempdir()?;
4274        let root = temp.path();
4275        fs::create_dir(root.join(".projectatlas"))?;
4276        #[cfg(not(windows))]
4277        let names = [".PROJECTATLAS", ".projectatlas\u{200d}"];
4278        #[cfg(windows)]
4279        let names = [
4280            ".PROJECTATLAS",
4281            ".projectatlas\u{200d}",
4282            ".projectatlas.",
4283            ".projectatlas ",
4284        ];
4285        for name in names {
4286            let directory = root.join(name);
4287            let aliases = directory.try_exists()?;
4288            if !aliases {
4289                fs::create_dir(&directory)?;
4290            }
4291            let config = directory.join("config.toml");
4292            let actual = load_atlas_config_from_text(&config, "[project]\nroot = \".\"\n")?;
4293            if actual.root != if aliases { root } else { &directory }.canonicalize()? {
4294                return Err(io::Error::other(format!(
4295                    "relative config root ignored native directory spelling: {name}"
4296                ))
4297                .into());
4298            }
4299            if aliases {
4300                let absent = load_atlas_config_from_text(&config, "[project]\n")?;
4301                if absent.root != root.canonicalize()?
4302                    || default_config_root_value(root, &config)? != "."
4303                {
4304                    return Err(io::Error::other(
4305                        "private config root inference disagreed with creation",
4306                    )
4307                    .into());
4308                }
4309            }
4310        }
4311        #[cfg(windows)]
4312        for name in [".projectatlas.", ".projectatlas "] {
4313            // canonicalize supplies a verbatim prefix, making these actual
4314            // distinct directory entries rather than ordinary Win32 aliases.
4315            let literal = root.canonicalize()?.join(name);
4316            fs::create_dir(&literal)?;
4317            let config = literal.join("config.toml");
4318            let actual = load_atlas_config_from_text(&config, "[project]\nroot = \".\"\n")?;
4319            if actual.root != literal.canonicalize()? {
4320                return Err(io::Error::other(
4321                    "literal private-looking directory changed config root semantics",
4322                )
4323                .into());
4324            }
4325            let text = super::default_config_text_for(root, &config)?;
4326            if load_atlas_config_from_text(&config, &text)?.root != root.canonicalize()? {
4327                return Err(io::Error::other(
4328                    "literal custom config initialization lost the requested root",
4329                )
4330                .into());
4331            }
4332        }
4333        Ok(())
4334    }
4335
4336    #[cfg(windows)]
4337    #[test]
4338    fn external_config_preserves_absolute_volume_guid_root() -> Result<(), Box<dyn Error>> {
4339        let temp = tempfile::tempdir()?;
4340        let root = Path::new(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\repo");
4341        let config_path = temp.path().join("external").join("projectatlas.toml");
4342
4343        let text = super::default_config_text_for(root, &config_path)?;
4344        let expected = root
4345            .to_str()
4346            .ok_or_else(|| io::Error::other("synthetic volume-GUID root was not UTF-8"))?;
4347        let parsed = toml::from_str::<toml::Value>(&text)?;
4348        let decoded = parsed
4349            .get("project")
4350            .and_then(toml::Value::as_table)
4351            .and_then(|project| project.get("root"))
4352            .and_then(toml::Value::as_str)
4353            .ok_or_else(|| io::Error::other("generated volume-GUID root was not a TOML string"))?;
4354        if !Path::new(decoded).is_absolute() || decoded != expected {
4355            return Err(io::Error::other(format!(
4356                "generated volume-GUID root lost its absolute native spelling: {decoded:?}"
4357            ))
4358            .into());
4359        }
4360        let escaped = expected.replace('\\', "\\\\");
4361        if !text.contains(&format!("root = \"{escaped}\"")) {
4362            return Err(io::Error::other("generated volume-GUID root was not TOML-escaped").into());
4363        }
4364        Ok(())
4365    }
4366
4367    #[cfg(windows)]
4368    #[test]
4369    fn reports_preserve_absolute_volume_guid_paths() -> Result<(), Box<dyn Error>> {
4370        let root = Path::new(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\repo");
4371        let config_path = root.join(".projectatlas").join("config.toml");
4372        let map_path = root.join(".projectatlas").join("projectatlas.toon");
4373        let nonsource_path = root
4374            .join(".projectatlas")
4375            .join("projectatlas-nonsource-files.toon");
4376        let database_path = root.join(".projectatlas").join("projectatlas.db");
4377        let mut config = test_config(map_path.clone());
4378        config.root = root.to_path_buf();
4379        config.nonsource_files_path = nonsource_path;
4380        config.db_path = database_path;
4381        let expected = |path: &Path| path.to_str().map(ToOwned::to_owned);
4382
4383        let effective = super::effective_config_report(&config);
4384        if effective.map_path != expected(&map_path)
4385            || effective.nonsource_files_path != expected(&config.nonsource_files_path)
4386            || effective.db_path != expected(&config.db_path)
4387        {
4388            return Err(io::Error::other(format!(
4389                "effective config report changed the volume-GUID spelling: {effective:?}"
4390            ))
4391            .into());
4392        }
4393
4394        let ignore = super::ignore_list_report(&config_path, &config);
4395        if ignore.config_path != expected(&config_path)
4396            || ignore.gitignore_path != expected(&root.join(".gitignore"))
4397        {
4398            return Err(io::Error::other(format!(
4399                "ignore list report changed the volume-GUID spelling: {ignore:?}"
4400            ))
4401            .into());
4402        }
4403        let mutation =
4404            super::ignore_mutation_report(&config_path, "add", "dir-name", "target", true, &config);
4405        if mutation.config_path != expected(&config_path)
4406            || mutation.gitignore_path != expected(&root.join(".gitignore"))
4407        {
4408            return Err(io::Error::other(format!(
4409                "ignore mutation report changed the volume-GUID spelling: {mutation:?}"
4410            ))
4411            .into());
4412        }
4413
4414        if super::effective_config_path_display(Path::new(r"\\?\C:\repo\file"))
4415            != Some(r"C:\repo\file".to_string())
4416            || super::effective_config_path_display(Path::new(r"\\?\UNC\server\share\repo\file"))
4417                != Some(r"\\server\share\repo\file".to_string())
4418        {
4419            return Err(io::Error::other(
4420                "ordinary extended drive or UNC display was not normalized",
4421            )
4422            .into());
4423        }
4424        Ok(())
4425    }
4426
4427    #[cfg(windows)]
4428    #[test]
4429    fn external_config_preserves_verbatim_drive_root() -> Result<(), Box<dyn Error>> {
4430        let temp = tempfile::tempdir()?;
4431        let root = Path::new(r"\\?\C:\repo\folder.");
4432        let config_path = temp.path().join("external").join("projectatlas.toml");
4433        let text = super::default_config_text_for(root, &config_path)?;
4434        let expected = root
4435            .to_str()
4436            .ok_or_else(|| io::Error::other("synthetic verbatim root was not UTF-8"))?;
4437        let parsed = toml::from_str::<toml::Value>(&text)?;
4438        let decoded = parsed
4439            .get("project")
4440            .and_then(toml::Value::as_table)
4441            .and_then(|project| project.get("root"))
4442            .and_then(toml::Value::as_str)
4443            .ok_or_else(|| io::Error::other("generated verbatim root was not a TOML string"))?;
4444        if !Path::new(decoded).is_absolute() || decoded != expected {
4445            return Err(io::Error::other(format!(
4446                "generated verbatim root changed its absolute spelling: {decoded:?}"
4447            ))
4448            .into());
4449        }
4450        let escaped = expected.replace('\\', "\\\\");
4451        if !text.contains(&format!("root = \"{escaped}\"")) {
4452            return Err(io::Error::other("generated verbatim root was not TOML-escaped").into());
4453        }
4454        Ok(())
4455    }
4456
4457    #[cfg(unix)]
4458    #[test]
4459    fn external_config_through_symlink_preserves_canonical_project_root()
4460    -> Result<(), Box<dyn Error>> {
4461        use std::os::unix::fs::symlink;
4462
4463        let temp = tempfile::tempdir()?;
4464        let root = temp.path().join("repo");
4465        let outside = temp.path().join("outside");
4466        fs::create_dir(&root)?;
4467        fs::create_dir(&outside)?;
4468        symlink(&outside, root.join("link"))?;
4469
4470        let config_path = root.join("link").join("nested").join("projectatlas.toml");
4471        super::init_project_with_config(&root, Some(&config_path))?;
4472
4473        let config = super::load_atlas_config(Some(&config_path))?;
4474        let expected_root = root.canonicalize()?;
4475        if config.root != expected_root {
4476            return Err(io::Error::other(format!(
4477                "symlinked external config selected {:?}, expected {expected_root:?}",
4478                config.root
4479            ))
4480            .into());
4481        }
4482        Ok(())
4483    }
4484
4485    #[cfg(unix)]
4486    #[test]
4487    fn external_config_preserves_unix_backslash_root() -> Result<(), Box<dyn Error>> {
4488        let temp = tempfile::tempdir()?;
4489        let root = temp.path().join("repo\\name");
4490        let config_path = temp.path().join("external").join("projectatlas.toml");
4491        fs::create_dir(&root)?;
4492
4493        super::init_project_with_config(&root, Some(&config_path))?;
4494
4495        let config_text = fs::read_to_string(&config_path)?;
4496        if !config_text.contains("repo\\\\name") {
4497            return Err(io::Error::other(
4498                "external config did not TOML-escape its literal Unix backslash",
4499            )
4500            .into());
4501        }
4502        let config = super::load_atlas_config(Some(&config_path))?;
4503        let expected_root = root.canonicalize()?;
4504        if config.root != expected_root {
4505            return Err(io::Error::other(format!(
4506                "external config selected {:?}, expected {expected_root:?}",
4507                config.root
4508            ))
4509            .into());
4510        }
4511        Ok(())
4512    }
4513
4514    #[cfg(unix)]
4515    #[test]
4516    fn external_config_rejects_non_utf8_root_before_writes() -> Result<(), Box<dyn Error>> {
4517        use std::ffi::OsString;
4518        use std::os::unix::ffi::OsStringExt;
4519
4520        let temp = tempfile::tempdir()?;
4521        let raw_root = temp.path().join(OsString::from_vec(b"repo-\x80".to_vec()));
4522        let config_parent = temp.path().join("external-config").join("nested");
4523        let config_path = config_parent.join("projectatlas.toml");
4524        fs::create_dir(&raw_root)?;
4525
4526        let result = super::init_project_with_config(&raw_root, Some(&config_path));
4527
4528        if !matches!(result, Err(AtlasMapError::InvalidRepositoryPath { .. })) {
4529            return Err(io::Error::other(format!(
4530                "external config initialization returned an unexpected result: {result:?}"
4531            ))
4532            .into());
4533        }
4534        if raw_root.join(".projectatlas").exists() || config_parent.exists() || config_path.exists()
4535        {
4536            return Err(io::Error::other(
4537                "external config refusal left project or config filesystem state",
4538            )
4539            .into());
4540        }
4541        Ok(())
4542    }
4543
4544    #[test]
4545    fn generated_at_is_render_time_not_legacy_snapshot_time() -> Result<(), Box<dyn Error>> {
4546        let temp = tempfile::tempdir()?;
4547        let map_path = temp.path().join("projectatlas.toon");
4548        let config = test_config(map_path.clone());
4549        let first = build_snapshot(&config)?;
4550        fs::write(
4551            &map_path,
4552            format!(
4553                "version: 1\ngenerated_at: unix:123\nfile_hash: \"{}\"\nfolder_hash: \"{}\"\n",
4554                first.file_hash, first.folder_hash
4555            ),
4556        )?;
4557        let second = build_snapshot(&config)?;
4558        if second.generated_at == "unix:123"
4559            || !second.generated_at.starts_with("unix:")
4560            || first.file_hash != second.file_hash
4561            || first.folder_hash != second.folder_hash
4562        {
4563            return Err(
4564                io::Error::other("map render reused a legacy timestamp or changed hashes").into(),
4565            );
4566        }
4567        Ok(())
4568    }
4569
4570    #[test]
4571    fn existing_map_rows_seed_imported_purposes() -> Result<(), Box<dyn std::error::Error>> {
4572        let temp = tempfile::tempdir()?;
4573        let map_path = temp.path().join("projectatlas.toon");
4574        std::fs::write(
4575            &map_path,
4576            [
4577                "version: 1",
4578                "folders[1]{path,summary,source}:",
4579                "  .,Repository root,database",
4580                "files[2]{path,summary,source}:",
4581                "  Cargo.toml,Rust workspace manifest,database",
4582                "  \"docs/a,b.md\",\"Quoted, summary\",database",
4583                "folder_summary_duplicates[]:",
4584            ]
4585            .join("\n"),
4586        )?;
4587        let config = test_config(map_path);
4588        let mut imported = BTreeMap::new();
4589
4590        append_existing_map_purpose_records(&config, &mut imported)?;
4591
4592        if imported.get(".").map(String::as_str) != Some("Repository root") {
4593            return Err(std::io::Error::other("root purpose was not imported").into());
4594        }
4595        if imported.get("Cargo.toml").map(String::as_str) != Some("Rust workspace manifest") {
4596            return Err(std::io::Error::other("Cargo purpose was not imported").into());
4597        }
4598        if imported.get("docs/a,b.md").map(String::as_str) != Some("Quoted, summary") {
4599            return Err(std::io::Error::other("quoted file purpose was not imported").into());
4600        }
4601        Ok(())
4602    }
4603}