projectatlas/
atlas_map.rs

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