Skip to main content

projectatlas/
atlas_map.rs

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