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
22pub fn parse_full(
28 content: &str,
29 reg: Option<®istry::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(§ion.body, section.kind, section.line, &mut errors);
39 all_entities.extend(entities);
40 }
41 }
42
43 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 §ion.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(§ion.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
88pub fn collect_referenced_registry_entities(
90 rels: &[Rel],
91 inline_entities: &[Entity],
92 reg: ®istry::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
113pub fn build_case_output(
116 path: &str,
117 reg: ®istry::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 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 if let Some(code) = writeback_registry_entities(&build_result.registry_pending, reg) {
175 return Err(code);
176 }
177
178 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
201fn writeback_registry_entities(
204 pending: &[(String, writeback::PendingId)],
205 reg: ®istry::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
236pub 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
264pub 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
277pub 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
306fn 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}