Skip to main content

weave_content/
lib.rs

1#![deny(unsafe_code)]
2#![deny(clippy::unwrap_used)]
3#![deny(clippy::expect_used)]
4#![allow(clippy::missing_errors_doc)]
5
6pub mod cache;
7pub mod entity;
8pub mod nulid_gen;
9pub mod output;
10pub mod parser;
11pub mod registry;
12pub mod relationship;
13pub mod thumbnail;
14pub mod timeline;
15pub mod verifier;
16pub mod writeback;
17
18use crate::entity::Entity;
19use crate::parser::{ParseError, ParsedCase, SectionKind};
20use crate::relationship::Rel;
21
22/// Parse a case file fully: front matter, entities, relationships, timeline.
23/// Returns the parsed case, inline entities, and relationships (including NEXT from timeline).
24///
25/// When a registry is provided, relationship and timeline names are resolved
26/// against both inline events AND the global entity registry.
27pub fn parse_full(
28    content: &str,
29    reg: Option<&registry::EntityRegistry>,
30) -> Result<(ParsedCase, Vec<Entity>, Vec<Rel>), Vec<ParseError>> {
31    let case = parser::parse(content)?;
32    let mut errors = Vec::new();
33
34    let mut all_entities = Vec::new();
35    for section in &case.sections {
36        if section.kind == SectionKind::Events {
37            let entities =
38                entity::parse_entities(&section.body, section.kind, section.line, &mut errors);
39            all_entities.extend(entities);
40        }
41    }
42
43    // Build combined name list: inline events + registry entities
44    let mut entity_names: Vec<&str> = all_entities.iter().map(|e| e.name.as_str()).collect();
45    if let Some(registry) = reg {
46        for name in registry.names() {
47            if !entity_names.contains(&name) {
48                entity_names.push(name);
49            }
50        }
51    }
52
53    let event_names: Vec<&str> = all_entities
54        .iter()
55        .filter(|e| e.label == entity::Label::PublicRecord)
56        .map(|e| e.name.as_str())
57        .collect();
58
59    let mut all_rels = Vec::new();
60    for section in &case.sections {
61        if section.kind == SectionKind::Relationships {
62            let rels = relationship::parse_relationships(
63                &section.body,
64                section.line,
65                &entity_names,
66                &case.sources,
67                &mut errors,
68            );
69            all_rels.extend(rels);
70        }
71    }
72
73    for section in &case.sections {
74        if section.kind == SectionKind::Timeline {
75            let rels =
76                timeline::parse_timeline(&section.body, section.line, &event_names, &mut errors);
77            all_rels.extend(rels);
78        }
79    }
80
81    if errors.is_empty() {
82        Ok((case, all_entities, all_rels))
83    } else {
84        Err(errors)
85    }
86}
87
88/// Collect registry entities referenced by relationships in this case.
89pub fn collect_referenced_registry_entities(
90    rels: &[Rel],
91    inline_entities: &[Entity],
92    reg: &registry::EntityRegistry,
93) -> Vec<Entity> {
94    let inline_names: Vec<&str> = inline_entities.iter().map(|e| e.name.as_str()).collect();
95    let mut referenced = Vec::new();
96    let mut seen_names: Vec<String> = Vec::new();
97
98    for rel in rels {
99        for name in [&rel.source_name, &rel.target_name] {
100            if !inline_names.contains(&name.as_str())
101                && !seen_names.contains(name)
102                && let Some(entry) = reg.get_by_name(name)
103            {
104                referenced.push(entry.entity.clone());
105                seen_names.push(name.clone());
106            }
107        }
108    }
109
110    referenced
111}
112
113/// Build a `CaseOutput` from a case file path.
114/// Handles parsing, ID writeback, and optionally thumbnail processing.
115pub fn build_case_output(
116    path: &str,
117    reg: &registry::EntityRegistry,
118    s3_config: Option<&thumbnail::S3Config>,
119) -> Result<output::CaseOutput, i32> {
120    let content = match std::fs::read_to_string(path) {
121        Ok(c) => c,
122        Err(e) => {
123            eprintln!("{path}: error reading file: {e}");
124            return Err(2);
125        }
126    };
127
128    let (case, entities, rels) = match parse_full(&content, Some(reg)) {
129        Ok(result) => result,
130        Err(errors) => {
131            for err in &errors {
132                eprintln!("{path}:{err}");
133            }
134            return Err(1);
135        }
136    };
137
138    let referenced_entities = collect_referenced_registry_entities(&rels, &entities, reg);
139
140    let build_result = match output::build_output(
141        &case.id,
142        &case.title,
143        &case.summary,
144        &case.sources,
145        &entities,
146        &rels,
147        &referenced_entities,
148    ) {
149        Ok(out) => out,
150        Err(errors) => {
151            for err in &errors {
152                eprintln!("{path}:{err}");
153            }
154            return Err(1);
155        }
156    };
157
158    let mut case_output = build_result.output;
159
160    // Write back generated IDs to source case file
161    if !build_result.case_pending.is_empty() {
162        let mut pending = build_result.case_pending;
163        if let Some(modified) = writeback::apply_writebacks(&content, &mut pending) {
164            if let Err(e) = writeback::write_file(std::path::Path::new(path), &modified) {
165                eprintln!("{e}");
166                return Err(2);
167            }
168            let count = pending.len();
169            eprintln!("{path}: wrote {count} generated ID(s) back to file");
170        }
171    }
172
173    // Write back generated IDs to entity files
174    if let Some(code) = writeback_registry_entities(&build_result.registry_pending, reg) {
175        return Err(code);
176    }
177
178    // Process thumbnails if S3 config is available
179    if let Some(config) = s3_config {
180        let rt = match tokio::runtime::Builder::new_current_thread()
181            .enable_all()
182            .build()
183        {
184            Ok(rt) => rt,
185            Err(e) => {
186                eprintln!("{path}: failed to create async runtime: {e}");
187                return Err(2);
188            }
189        };
190        thumbnail::process_thumbnails(&mut case_output, config, &rt);
191    }
192
193    eprintln!(
194        "{path}: built ({} nodes, {} relationships)",
195        case_output.nodes.len(),
196        case_output.relationships.len()
197    );
198    Ok(case_output)
199}
200
201/// Write back generated IDs to registry entity files.
202/// Returns `Some(exit_code)` on error, `None` on success.
203fn writeback_registry_entities(
204    pending: &[(String, writeback::PendingId)],
205    reg: &registry::EntityRegistry,
206) -> Option<i32> {
207    for (entity_name, pending_id) in pending {
208        let Some(entry) = reg.get_by_name(entity_name) else {
209            continue;
210        };
211        let entity_path = &entry.path;
212        let entity_content = match std::fs::read_to_string(entity_path) {
213            Ok(c) => c,
214            Err(e) => {
215                eprintln!("{}: error reading file: {e}", entity_path.display());
216                return Some(2);
217            }
218        };
219        let fm_end = writeback::find_front_matter_end(&entity_content);
220        let mut ids = vec![writeback::PendingId {
221            line: fm_end.unwrap_or(2),
222            id: pending_id.id.clone(),
223            kind: writeback::WriteBackKind::EntityFrontMatter,
224        }];
225        if let Some(modified) = writeback::apply_writebacks(&entity_content, &mut ids) {
226            if let Err(e) = writeback::write_file(entity_path, &modified) {
227                eprintln!("{e}");
228                return Some(2);
229            }
230            eprintln!("{}: wrote generated ID back to file", entity_path.display());
231        }
232    }
233    None
234}
235
236/// Resolve the content root directory.
237///
238/// Priority: explicit `--root` flag > parent of given path > current directory.
239pub fn resolve_content_root(path: Option<&str>, root: Option<&str>) -> std::path::PathBuf {
240    if let Some(r) = root {
241        return std::path::PathBuf::from(r);
242    }
243    if let Some(p) = path {
244        let p = std::path::Path::new(p);
245        if p.is_file() {
246            if let Some(parent) = p.parent() {
247                for ancestor in parent.ancestors() {
248                    if ancestor.join("cases").is_dir()
249                        || ancestor.join("actors").is_dir()
250                        || ancestor.join("institutions").is_dir()
251                    {
252                        return ancestor.to_path_buf();
253                    }
254                }
255                return parent.to_path_buf();
256            }
257        } else if p.is_dir() {
258            return p.to_path_buf();
259        }
260    }
261    std::path::PathBuf::from(".")
262}
263
264/// Load entity registry from content root. Returns empty registry if no entity dirs exist.
265pub fn load_registry(content_root: &std::path::Path) -> Result<registry::EntityRegistry, i32> {
266    match registry::EntityRegistry::load(content_root) {
267        Ok(reg) => Ok(reg),
268        Err(errors) => {
269            for err in &errors {
270                eprintln!("registry: {err}");
271            }
272            Err(1)
273        }
274    }
275}
276
277/// Resolve case file paths from path argument.
278/// If path is a file, returns just that file.
279/// If path is a directory (or None), auto-discovers `cases/**/*.md`.
280pub fn resolve_case_files(
281    path: Option<&str>,
282    content_root: &std::path::Path,
283) -> Result<Vec<String>, i32> {
284    if let Some(p) = path {
285        let p_path = std::path::Path::new(p);
286        if p_path.is_file() {
287            return Ok(vec![p.to_string()]);
288        }
289        if !p_path.is_dir() {
290            eprintln!("{p}: not a file or directory");
291            return Err(2);
292        }
293    }
294
295    let cases_dir = content_root.join("cases");
296    if !cases_dir.is_dir() {
297        return Ok(Vec::new());
298    }
299
300    let mut files = Vec::new();
301    discover_md_files(&cases_dir, &mut files, 0);
302    files.sort();
303    Ok(files)
304}
305
306/// Recursively discover .md files in a directory (max 3 levels deep for cases/year/topic/).
307fn discover_md_files(dir: &std::path::Path, files: &mut Vec<String>, depth: usize) {
308    const MAX_DEPTH: usize = 3;
309    if depth > MAX_DEPTH {
310        return;
311    }
312
313    let Ok(entries) = std::fs::read_dir(dir) else {
314        return;
315    };
316
317    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
318    entries.sort_by_key(std::fs::DirEntry::file_name);
319
320    for entry in entries {
321        let path = entry.path();
322        if path.is_dir() {
323            discover_md_files(&path, files, depth + 1);
324        } else if path.extension().and_then(|e| e.to_str()) == Some("md")
325            && let Some(s) = path.to_str()
326        {
327            files.push(s.to_string());
328        }
329    }
330}