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