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