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