Skip to main content

specfold_core/
import.rs

1//! Importing documents from another layout through a project's import map.
2
3pub mod convert;
4pub mod key;
5pub mod map;
6pub mod owner;
7pub mod plan;
8pub mod sources;
9
10pub use map::ImportMap;
11pub use plan::{ImportPlan, Row, alias_table, build};
12
13#[cfg(test)]
14mod tests {
15    use super::*;
16    use crate::config::{Config, OwnerKind, RawConfig};
17    use crate::corpus::{Corpus, LoadOptions};
18
19    fn write(root: &std::path::Path, rel: &str, text: &str) {
20        let p = root.join(rel);
21        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
22        std::fs::write(p, text).unwrap();
23    }
24
25    #[test]
26    fn imports_docs_bundles_and_stubs() {
27        let tmp = tempfile::tempdir().unwrap();
28        let r = tmp.path();
29        std::fs::create_dir_all(r.join("services/shop/src")).unwrap();
30        write(
31            r,
32            "docs/adr/0007-use-postgres.md",
33            "---\nstatus: accepted\ndate: 2026-01-05\n---\n# Use Postgres\nTouches services/shop/src/db.rs and services/shop/src/x.rs\n",
34        );
35        write(r, "docs/plans/2026-02-01-launch/README.md", "# Launch\n");
36        write(r, "docs/plans/2026-02-01-launch/10-steps.md", "# Steps\n");
37        write(r, "docs/plans/2026-02-01-launch/board.png", "png");
38        write(r, "old/launch.md", "> Moved to `docs/plans/2026-02-01-launch`\n");
39        write(r, "old/notes.md", "# Notes\nA real document next to the stub.\n");
40        let map: ImportMap = toml::from_str(
41            r#"
42[status]
43accepted = "active"
44[[sources]]
45glob = "docs/adr/*.md"
46type = "adr"
47key = "created"
48name = '^(?P<alias>\d{4})-(?P<slug>.+)\.md$'
49[[sources]]
50glob = "docs/plans/*/"
51type = "research"
52key = "name"
53name = '^(?P<key>\d{4}-\d{2}-\d{2})-(?P<slug>.+)$'
54default_status = "archived"
55[[sources]]
56glob = "old/*.md"
57redirect = 'Moved to `(?P<target>[^`]+)`'
58[[sources]]
59glob = "old/*.md"
60type = "guide"
61"#,
62        )
63        .unwrap();
64        let mut raw = RawConfig::default();
65        raw.owners.kinds = vec![OwnerKind { pattern: "services/*".into(), id: "{1}".into(), manifest: false }];
66        let c = Corpus::load(Config::from_raw(r.to_path_buf(), raw, false), &LoadOptions::default());
67        let p = build(&c, &map).unwrap();
68        let ids: Vec<&str> = p.rows.iter().map(|r| r.id.as_str()).collect();
69        assert_eq!(
70            ids,
71            [
72                "adr:shop/20260105000000",
73                "research:root/20260201000000",
74                "guide:root/notes",
75                "research:root/20260201000000"
76            ]
77        );
78        assert_eq!(p.rows[0].status_to, "active");
79        assert_eq!(p.rows[0].aliases, ["0007"]);
80        assert_eq!(p.rows[1].path, ".spec/research/20260201000000-launch/index.research.md");
81        assert!(p.alias_rows.contains(&("old/launch.md".to_string(), "research:root/20260201000000".to_string())));
82        let chapter =
83            ("docs/plans/2026-02-01-launch/10-steps.md".to_string(), "research:root/20260201000000".to_string());
84        assert!(p.alias_rows.contains(&chapter), "a chapter's old path names its document");
85        p.plan.apply(r).unwrap();
86        let c2 = Corpus::load(Config::from_raw(r.to_path_buf(), c.cfg.raw.clone(), false), &LoadOptions::default());
87        assert_eq!(c2.docs.len(), 3);
88        assert!(c2.strays.is_empty(), "{:?}", c2.strays);
89        assert!(!r.join("docs/adr/0007-use-postgres.md").exists());
90    }
91
92    #[test]
93    fn a_nested_bundle_belongs_to_the_first_rule_and_names_resolve_by_stem() {
94        let tmp = tempfile::tempdir().unwrap();
95        let r = tmp.path();
96        write(r, "notes/catalog/2026-03-01-round/README.md", "# Round\n");
97        write(r, "notes/catalog/2026-03-01-round/data.json", "{}");
98        write(r, "notes/catalog/overview.md", "# Catalog\n");
99        let map: ImportMap = toml::from_str(
100            "stem_aliases = true\n[[sources]]\nglob = \"notes/*/*/\"\ntype = \"research\"\ndefault_status = \"active\"\n[[sources]]\nglob = \"notes/*/\"\ntype = \"research\"\ndefault_status = \"active\"\n",
101        )
102        .unwrap();
103        let c = Corpus::load(Config::from_raw(r.to_path_buf(), RawConfig::default(), false), &LoadOptions::default());
104        let p = build(&c, &map).unwrap();
105        assert_eq!(p.rows.len(), 2);
106        let outer =
107            p.alias_rows.iter().filter(|(_, id)| id == &p.rows[1].id).map(|(old, _)| old.as_str()).collect::<Vec<_>>();
108        assert!(
109            !outer.iter().any(|o| o.contains("2026-03-01-round/")),
110            "the outer bundle took nested files: {outer:?}"
111        );
112        assert!(p.alias_rows.contains(&("2026-03-01-round".to_string(), p.rows[0].id.clone())));
113    }
114
115    #[test]
116    fn raw_files_are_wrapped_and_old_indexes_dropped() {
117        let tmp = tempfile::tempdir().unwrap();
118        let r = tmp.path();
119        write(r, "raw/2026-01-02-run.json", "{}");
120        write(r, "raw/2026-01-03-batch/a.csv", "x");
121        write(r, "raw/2026-01-03-batch/b.csv", "y");
122        write(r, "old/INDEX.md", "| generated |\n");
123        let map: ImportMap = toml::from_str(
124            "[[sources]]\nglob = \"old/INDEX.md\"\ndrop = true\n[[sources]]\nglob = \"raw/*/\"\ntype = \"evidence\"\nwrap = true\nkey = \"name\"\nname = '^(?P<key>\\d{4}-\\d{2}-\\d{2})-(?P<slug>.+)$'\n[[sources]]\nglob = \"raw/*\"\ntype = \"evidence\"\nwrap = true\nkey = \"name\"\nname = '^(?P<key>\\d{4}-\\d{2}-\\d{2})-(?P<slug>.+)\\.json$'\n",
125        )
126        .unwrap();
127        let c = Corpus::load(Config::from_raw(r.to_path_buf(), RawConfig::default(), false), &LoadOptions::default());
128        let p = build(&c, &map).unwrap();
129        let kinds: Vec<&str> = p.rows.iter().map(|r| r.kind.as_str()).collect();
130        assert_eq!(kinds, ["drop", "doc", "doc"]);
131        p.plan.apply(r).unwrap();
132        let c2 = Corpus::load(Config::from_raw(r.to_path_buf(), RawConfig::default(), false), &LoadOptions::default());
133        assert_eq!(c2.docs.len(), 2);
134        let batch = c2.docs.iter().find(|d| d.id.key == "20260103000000").unwrap();
135        assert_eq!(batch.files().len(), 2);
136        assert_eq!(batch.title.as_deref(), Some("batch"));
137        assert!(!r.join("old/INDEX.md").exists() && !r.join("raw/2026-01-02-run.json").exists());
138    }
139}