1use std::cmp::Ordering;
2use std::collections::{BTreeSet, HashMap};
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, SecondsFormat, Utc};
8use ix_config::{ConfigLoader, IxchelConfig};
9use serde_yaml::{Mapping, Value};
10use thiserror::Error;
11
12use crate::entity::{EntityKind, kind_from_id, looks_like_entity_id};
13use crate::markdown::{
14 MarkdownDocument, MarkdownError, get_string, get_string_list, parse_markdown, render_markdown,
15 set_string, set_string_list,
16};
17use crate::paths::{IxchelPaths, find_git_root};
18
19#[derive(Debug, Clone)]
20pub struct EntitySummary {
21 pub id: String,
22 pub kind: EntityKind,
23 pub title: String,
24 pub path: PathBuf,
25}
26
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum ListSort {
29 #[default]
30 CreatedDesc,
31 UpdatedDesc,
32}
33
34#[derive(Debug, Error)]
35pub enum ParseListSortError {
36 #[error("Unknown sort option: {0}")]
37 UnknownSort(String),
38}
39
40impl FromStr for ListSort {
41 type Err = ParseListSortError;
42
43 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
44 let normalized = s.trim().to_ascii_lowercase();
45 match normalized.as_str() {
46 "recent" | "created" | "created_desc" | "created-desc" | "createddesc" => {
47 Ok(Self::CreatedDesc)
48 }
49 "updated" | "updated_desc" | "updated-desc" | "updateddesc" => Ok(Self::UpdatedDesc),
50 _ => Err(ParseListSortError::UnknownSort(s.to_string())),
51 }
52 }
53}
54
55#[derive(Debug)]
56pub struct CheckReport {
57 pub errors: Vec<CheckError>,
58}
59
60#[derive(Debug)]
61pub struct CheckError {
62 pub path: PathBuf,
63 pub message: String,
64}
65
66#[derive(Debug)]
67pub struct CheckIssue {
68 pub path: PathBuf,
69 pub message: String,
70 pub suggestion: Option<String>,
71}
72
73#[derive(Debug)]
74pub struct CheckReportDetailed {
75 pub errors: Vec<CheckIssue>,
76}
77
78#[derive(Debug)]
79pub struct IxchelRepo {
80 pub paths: IxchelPaths,
81 pub config: IxchelConfig,
82}
83
84const METADATA_KEYS: &[&str] = &[
85 "id",
86 "type",
87 "title",
88 "status",
89 "date",
90 "created_at",
91 "updated_at",
92 "created_by",
93 "tags",
94];
95
96const KNOWN_ID_PREFIXES_HINT: &str = "dec, iss, bd, idea, rpt, src, cite, agt, ses";
97
98impl IxchelRepo {
99 pub fn open_from(start: &Path) -> Result<Self> {
100 let repo_root = find_git_root(start).with_context(|| {
101 format!(
102 "Not inside a git repository (no .git found above {})",
103 start.display()
104 )
105 })?;
106
107 let paths = IxchelPaths::new(repo_root);
108 let ixchel_dir = paths.ixchel_dir();
109 if !ixchel_dir.exists() {
110 anyhow::bail!(
111 "Ixchel is not initialized (missing {}). Run `ixchel init`.",
112 ixchel_dir.display()
113 );
114 }
115
116 let config: IxchelConfig = ConfigLoader::new("").with_project_dir(ixchel_dir).load()?;
117
118 Ok(Self { paths, config })
119 }
120
121 pub fn init_from(start: &Path, force: bool) -> Result<Self> {
122 let repo_root = find_git_root(start).with_context(|| {
123 format!(
124 "Not inside a git repository (no .git found above {})",
125 start.display()
126 )
127 })?;
128
129 Self::init_at(&repo_root, force)
130 }
131
132 pub fn init_at(repo_root: &Path, force: bool) -> Result<Self> {
133 let paths = IxchelPaths::new(repo_root.to_path_buf());
134 let ixchel_dir = paths.ixchel_dir();
135
136 if ixchel_dir.exists() && !force {
137 anyhow::bail!(
138 "{} already exists. Re-run with --force to recreate the directory layout.",
139 ixchel_dir.display()
140 );
141 }
142
143 std::fs::create_dir_all(&ixchel_dir)
144 .with_context(|| format!("Failed to create {}", ixchel_dir.display()))?;
145 paths.ensure_layout()?;
146 ensure_project_gitignore(repo_root)?;
147
148 let config_path = paths.config_path();
149 if force || !config_path.exists() {
150 IxchelConfig::default().save(&config_path)?;
151 }
152
153 let config: IxchelConfig = ConfigLoader::new("").with_project_dir(ixchel_dir).load()?;
154 Ok(Self { paths, config })
155 }
156
157 pub fn create_entity(
158 &self,
159 kind: EntityKind,
160 title: &str,
161 status: Option<&str>,
162 ) -> Result<EntitySummary> {
163 let created_by = default_actor();
164 let now = Utc::now();
165
166 let id = ix_id::id_random(kind.id_prefix());
167 let path = self.paths.kind_dir(kind).join(format!("{id}.md"));
168
169 if path.exists() {
170 anyhow::bail!("Entity already exists: {}", path.display());
171 }
172
173 let mut frontmatter = Mapping::new();
174 frontmatter.insert(Value::String("id".to_string()), Value::String(id.clone()));
175 frontmatter.insert(
176 Value::String("type".to_string()),
177 Value::String(kind.as_str().to_string()),
178 );
179 frontmatter.insert(
180 Value::String("title".to_string()),
181 Value::String(title.to_string()),
182 );
183
184 if let Some(status) = status {
185 frontmatter.insert(
186 Value::String("status".to_string()),
187 Value::String(status.to_string()),
188 );
189 }
190
191 frontmatter.insert(
192 Value::String("created_at".to_string()),
193 Value::String(now.to_rfc3339_opts(SecondsFormat::Secs, true)),
194 );
195 frontmatter.insert(
196 Value::String("updated_at".to_string()),
197 Value::String(now.to_rfc3339_opts(SecondsFormat::Secs, true)),
198 );
199 if let Some(created_by) = created_by {
200 frontmatter.insert(
201 Value::String("created_by".to_string()),
202 Value::String(created_by),
203 );
204 }
205 frontmatter.insert(
206 Value::String("tags".to_string()),
207 Value::Sequence(Vec::new()),
208 );
209
210 let body = default_template(kind);
211 let doc = MarkdownDocument { frontmatter, body };
212 let markdown = render_markdown(&doc)?;
213
214 std::fs::write(&path, markdown)
215 .with_context(|| format!("Failed to write {}", path.display()))?;
216
217 Ok(EntitySummary {
218 id,
219 kind,
220 title: title.to_string(),
221 path,
222 })
223 }
224
225 pub fn read_raw(&self, id: &str) -> Result<String> {
226 let path = self
227 .paths
228 .entity_path(id)
229 .with_context(|| format!("Unknown entity id prefix: {id}"))?;
230
231 std::fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
232 }
233
234 pub fn delete_entity(&self, id: &str) -> Result<()> {
235 let path = self
236 .paths
237 .entity_path(id)
238 .with_context(|| format!("Unknown entity id prefix: {id}"))?;
239
240 if !path.exists() {
241 anyhow::bail!("Entity does not exist: {id} ({})", path.display());
242 }
243
244 std::fs::remove_file(&path)
245 .with_context(|| format!("Failed to delete {}", path.display()))?;
246 Ok(())
247 }
248
249 pub fn list(&self, kind: Option<EntityKind>, sort: ListSort) -> Result<Vec<EntitySummary>> {
250 let mut out = Vec::new();
251
252 let kinds: Vec<EntityKind> = kind.map_or_else(
253 || {
254 vec![
255 EntityKind::Decision,
256 EntityKind::Issue,
257 EntityKind::Idea,
258 EntityKind::Report,
259 EntityKind::Source,
260 EntityKind::Citation,
261 EntityKind::Agent,
262 EntityKind::Session,
263 ]
264 },
265 |k| vec![k],
266 );
267
268 for kind in kinds {
269 let dir = self.paths.kind_dir(kind);
270 if !dir.exists() {
271 continue;
272 }
273
274 for entry in std::fs::read_dir(&dir)
275 .with_context(|| format!("Failed to read {}", dir.display()))?
276 {
277 let entry = entry?;
278 let path = entry.path();
279 if path.extension().and_then(|s| s.to_str()) != Some("md") {
280 continue;
281 }
282
283 let raw = std::fs::read_to_string(&path)
284 .with_context(|| format!("Failed to read {}", path.display()))?;
285 let doc = parse_markdown(&path, &raw)?;
286
287 let id = get_string(&doc.frontmatter, "id")
288 .or_else(|| {
289 path.file_stem()
290 .and_then(|s| s.to_str())
291 .map(std::string::ToString::to_string)
292 })
293 .unwrap_or_default();
294 let title = get_string(&doc.frontmatter, "title").unwrap_or_default();
295
296 let summary = EntitySummary {
297 id,
298 kind,
299 title,
300 path,
301 };
302
303 let sort_ts = match sort {
304 ListSort::CreatedDesc => parse_timestamp(&doc.frontmatter, "created_at"),
305 ListSort::UpdatedDesc => parse_timestamp(&doc.frontmatter, "updated_at"),
306 };
307
308 out.push(ListEntry { summary, sort_ts });
309 }
310 }
311
312 out.sort_by(|a, b| {
313 cmp_timestamp_desc(
314 a.sort_ts.as_ref(),
315 b.sort_ts.as_ref(),
316 &a.summary.id,
317 &b.summary.id,
318 )
319 });
320
321 Ok(out.into_iter().map(|entry| entry.summary).collect())
322 }
323
324 pub fn collect_tags(&self, kind: Option<EntityKind>) -> Result<HashMap<String, Vec<String>>> {
325 let mut out: HashMap<String, Vec<String>> = HashMap::new();
326
327 for item in self.list(kind, ListSort::default())? {
328 let raw = std::fs::read_to_string(&item.path)
329 .with_context(|| format!("Failed to read {}", item.path.display()))?;
330 let doc = parse_markdown(&item.path, &raw)?;
331 let tags = normalized_tags_vec(&doc.frontmatter);
332 if tags.is_empty() {
333 continue;
334 }
335
336 for tag in tags {
337 out.entry(tag).or_default().push(item.id.clone());
338 }
339 }
340
341 Ok(out)
342 }
343
344 pub fn list_untagged(&self, kind: Option<EntityKind>) -> Result<Vec<EntitySummary>> {
345 let mut out = Vec::new();
346
347 for item in self.list(kind, ListSort::default())? {
348 let raw = std::fs::read_to_string(&item.path)
349 .with_context(|| format!("Failed to read {}", item.path.display()))?;
350 let doc = parse_markdown(&item.path, &raw)?;
351 if normalized_tags_vec(&doc.frontmatter).is_empty() {
352 out.push(item);
353 }
354 }
355
356 out.sort_by(|a, b| a.id.cmp(&b.id));
357 Ok(out)
358 }
359
360 pub fn add_tags(&self, id: &str, tags: &[String]) -> Result<bool> {
361 let path = self
362 .paths
363 .entity_path(id)
364 .with_context(|| format!("Unknown entity id prefix: {id}"))?;
365 let raw = std::fs::read_to_string(&path)
366 .with_context(|| format!("Failed to read {}", path.display()))?;
367 let mut doc = parse_markdown(&path, &raw)?;
368
369 let mut existing = normalized_tags_vec(&doc.frontmatter);
370 let mut changed = false;
371 for tag in tags {
372 let Some(tag) = normalize_tag(tag) else {
373 continue;
374 };
375 if existing.iter().any(|value| value == &tag) {
376 continue;
377 }
378 existing.push(tag);
379 changed = true;
380 }
381
382 if !changed {
383 return Ok(false);
384 }
385
386 set_string_list(&mut doc.frontmatter, "tags", existing);
387 let now = Utc::now();
388 set_string(
389 &mut doc.frontmatter,
390 "updated_at",
391 now.to_rfc3339_opts(SecondsFormat::Secs, true),
392 );
393
394 let out = render_markdown(&doc)?;
395 std::fs::write(&path, out)
396 .with_context(|| format!("Failed to write {}", path.display()))?;
397 Ok(true)
398 }
399
400 pub fn remove_tags(&self, id: &str, tags: &[String]) -> Result<bool> {
401 let path = self
402 .paths
403 .entity_path(id)
404 .with_context(|| format!("Unknown entity id prefix: {id}"))?;
405 let raw = std::fs::read_to_string(&path)
406 .with_context(|| format!("Failed to read {}", path.display()))?;
407 let mut doc = parse_markdown(&path, &raw)?;
408
409 let to_remove = tags
410 .iter()
411 .filter_map(|tag| normalize_tag(tag))
412 .collect::<BTreeSet<_>>();
413 if to_remove.is_empty() {
414 return Ok(false);
415 }
416
417 let mut existing = normalized_tags_vec(&doc.frontmatter);
418 let before_len = existing.len();
419 existing.retain(|tag| !to_remove.contains(tag));
420
421 if existing.len() == before_len {
422 return Ok(false);
423 }
424
425 set_string_list(&mut doc.frontmatter, "tags", existing);
426 let now = Utc::now();
427 set_string(
428 &mut doc.frontmatter,
429 "updated_at",
430 now.to_rfc3339_opts(SecondsFormat::Secs, true),
431 );
432
433 let out = render_markdown(&doc)?;
434 std::fs::write(&path, out)
435 .with_context(|| format!("Failed to write {}", path.display()))?;
436 Ok(true)
437 }
438
439 pub fn link(&self, from_id: &str, rel: &str, to_id: &str) -> Result<()> {
440 let from_path = self
441 .paths
442 .entity_path(from_id)
443 .with_context(|| format!("Unknown entity id prefix: {from_id}"))?;
444 let to_path = self
445 .paths
446 .entity_path(to_id)
447 .with_context(|| format!("Unknown entity id prefix: {to_id}"))?;
448
449 if !to_path.exists() {
450 anyhow::bail!("Target does not exist: {to_id} ({})", to_path.display());
451 }
452
453 let raw = std::fs::read_to_string(&from_path)
454 .with_context(|| format!("Failed to read {}", from_path.display()))?;
455 let mut doc = parse_markdown(&from_path, &raw)?;
456
457 let mut values = get_string_list(&doc.frontmatter, rel);
458 if !values.iter().any(|v| v == to_id) {
459 values.push(to_id.to_string());
460 }
461 set_string_list(&mut doc.frontmatter, rel, values);
462
463 let now = Utc::now();
464 set_string(
465 &mut doc.frontmatter,
466 "updated_at",
467 now.to_rfc3339_opts(SecondsFormat::Secs, true),
468 );
469
470 let out = render_markdown(&doc)?;
471 std::fs::write(&from_path, out)
472 .with_context(|| format!("Failed to write {}", from_path.display()))?;
473 Ok(())
474 }
475
476 pub fn unlink(&self, from_id: &str, rel: &str, to_id: &str) -> Result<bool> {
477 let from_path = self
478 .paths
479 .entity_path(from_id)
480 .with_context(|| format!("Unknown entity id prefix: {from_id}"))?;
481
482 let raw = std::fs::read_to_string(&from_path)
483 .with_context(|| format!("Failed to read {}", from_path.display()))?;
484 let mut doc = parse_markdown(&from_path, &raw)?;
485
486 let mut values = get_string_list(&doc.frontmatter, rel);
487 let before_len = values.len();
488 values.retain(|v| v != to_id);
489
490 if values.len() == before_len {
491 return Ok(false);
492 }
493
494 if values.is_empty() {
495 doc.frontmatter.remove(Value::String(rel.to_string()));
496 } else {
497 set_string_list(&mut doc.frontmatter, rel, values);
498 }
499
500 let now = Utc::now();
501 set_string(
502 &mut doc.frontmatter,
503 "updated_at",
504 now.to_rfc3339_opts(SecondsFormat::Secs, true),
505 );
506
507 let out = render_markdown(&doc)?;
508 std::fs::write(&from_path, out)
509 .with_context(|| format!("Failed to write {}", from_path.display()))?;
510
511 Ok(true)
512 }
513
514 pub fn check(&self) -> Result<CheckReport> {
515 let report = self.check_with_suggestions()?;
516 Ok(CheckReport {
517 errors: report
518 .errors
519 .into_iter()
520 .map(|issue| CheckError {
521 path: issue.path,
522 message: issue.message,
523 })
524 .collect(),
525 })
526 }
527
528 pub fn check_with_suggestions(&self) -> Result<CheckReportDetailed> {
529 let mut errors = Vec::new();
530 let mut seen_ids: BTreeSet<String> = BTreeSet::new();
531
532 let kinds = [
533 EntityKind::Decision,
534 EntityKind::Issue,
535 EntityKind::Idea,
536 EntityKind::Report,
537 EntityKind::Source,
538 EntityKind::Citation,
539 EntityKind::Agent,
540 EntityKind::Session,
541 ];
542
543 for kind in kinds {
544 let dir = self.paths.kind_dir(kind);
545 if !dir.exists() {
546 continue;
547 }
548
549 let mut entries = Vec::new();
550 for entry in std::fs::read_dir(&dir)
551 .with_context(|| format!("Failed to read {}", dir.display()))?
552 {
553 let entry = entry?;
554 let path = entry.path();
555 if path.extension().and_then(|s| s.to_str()) != Some("md") {
556 continue;
557 }
558 entries.push(path);
559 }
560
561 entries.sort();
562 for path in entries {
563 check_document(&self.paths, kind, &path, &mut seen_ids, &mut errors)?;
564 }
565 }
566
567 Ok(CheckReportDetailed { errors })
568 }
569}
570
571fn check_document(
572 paths: &IxchelPaths,
573 kind: EntityKind,
574 path: &Path,
575 seen_ids: &mut BTreeSet<String>,
576 errors: &mut Vec<CheckIssue>,
577) -> Result<()> {
578 let raw = std::fs::read_to_string(path)
579 .with_context(|| format!("Failed to read {}", path.display()))?;
580 let file_id = path
581 .file_stem()
582 .and_then(|s| s.to_str())
583 .unwrap_or("")
584 .to_string();
585 let has_frontmatter = raw.lines().next().is_some_and(|line| line == "---");
586
587 let doc = parse_document_with_issue(path, &raw, errors);
588 if !has_frontmatter {
589 let id_hint = id_hint(&file_id, kind);
590 let suggestion = format!(
591 "Add YAML frontmatter starting with `---` and include `id: {id_hint}`, `type: {}`, `title`, `created_at`, `updated_at`, and `tags`.",
592 kind.as_str()
593 );
594 push_issue(errors, path, "missing frontmatter block", Some(suggestion));
595 }
596
597 let frontmatter = if has_frontmatter {
598 doc.as_ref().map(|doc| &doc.frontmatter)
599 } else {
600 None
601 };
602
603 let frontmatter_id = frontmatter
604 .and_then(|frontmatter| check_frontmatter_id(frontmatter, &file_id, kind, path, errors));
605 let resolved_id = frontmatter_id.as_deref().unwrap_or(file_id.as_str());
606 check_id_and_path(resolved_id, kind, path, seen_ids, errors);
607
608 if let Some(frontmatter) = frontmatter {
609 check_frontmatter_fields(frontmatter, kind, path, errors);
610 check_relationships(paths, frontmatter, path, errors);
611 }
612
613 Ok(())
614}
615
616fn parse_document_with_issue(
617 path: &Path,
618 raw: &str,
619 errors: &mut Vec<CheckIssue>,
620) -> Option<MarkdownDocument> {
621 match parse_markdown(path, raw) {
622 Ok(doc) => Some(doc),
623 Err(err) => {
624 let (message, suggestion) = match err {
625 MarkdownError::UnclosedFrontmatter { .. } => (
626 "frontmatter missing closing delimiter '---'".to_string(),
627 "Add a closing `---` line after the YAML frontmatter.".to_string(),
628 ),
629 MarkdownError::FrontmatterParse { source, .. } => (
630 format!("frontmatter YAML parse error: {source}"),
631 "Fix YAML syntax; frontmatter must be a mapping of key/value pairs."
632 .to_string(),
633 ),
634 MarkdownError::FrontmatterNotMapping { .. } => (
635 "frontmatter must be a YAML mapping".to_string(),
636 "Replace frontmatter with key/value mapping (for example: `id: ...`)."
637 .to_string(),
638 ),
639 MarkdownError::FrontmatterSerialize { source } => (
640 format!("frontmatter serialization error: {source}"),
641 "Fix frontmatter values so they can be serialized to YAML.".to_string(),
642 ),
643 };
644 push_issue(errors, path, message, Some(suggestion));
645 None
646 }
647 }
648}
649
650fn check_frontmatter_id(
651 frontmatter: &Mapping,
652 file_id: &str,
653 kind: EntityKind,
654 path: &Path,
655 errors: &mut Vec<CheckIssue>,
656) -> Option<String> {
657 match frontmatter.get(Value::String("id".to_string())) {
658 Some(Value::String(value)) => {
659 let trimmed = value.trim();
660 if trimmed.is_empty() {
661 let id_hint = id_hint(file_id, kind);
662 push_issue(
663 errors,
664 path,
665 "missing frontmatter id",
666 Some(format!("Add `id: {id_hint}` to frontmatter.")),
667 );
668 None
669 } else {
670 Some(trimmed.to_string())
671 }
672 }
673 Some(_) => {
674 push_issue(
675 errors,
676 path,
677 "frontmatter id must be a string",
678 Some("Set `id` to a string like `iss-a1b2c3`.".to_string()),
679 );
680 None
681 }
682 None => {
683 let id_hint = id_hint(file_id, kind);
684 push_issue(
685 errors,
686 path,
687 "missing frontmatter id",
688 Some(format!("Add `id: {id_hint}` to frontmatter.")),
689 );
690 None
691 }
692 }
693}
694
695fn check_id_and_path(
696 id: &str,
697 kind: EntityKind,
698 path: &Path,
699 seen_ids: &mut BTreeSet<String>,
700 errors: &mut Vec<CheckIssue>,
701) {
702 let trimmed = id.trim();
703 if trimmed.is_empty() {
704 return;
705 }
706
707 let mut id_format_ok = true;
708 if ix_id::parse_id(trimmed).is_err() {
709 id_format_ok = false;
710 push_issue(
711 errors,
712 path,
713 "id is not a valid Ixchel id",
714 Some("Use `<prefix>-<6..12 hex>`, for example `iss-a1b2c3`.".to_string()),
715 );
716 }
717
718 if !seen_ids.insert(trimmed.to_string()) {
719 push_issue(
720 errors,
721 path,
722 format!("duplicate id: {trimmed}"),
723 Some("Make ids unique and rename the file to match the new id.".to_string()),
724 );
725 }
726
727 if id_format_ok {
728 let expected_kind = kind_from_id(trimmed);
729 if expected_kind != Some(kind) {
730 let suggestion = if expected_kind.is_none() {
731 format!(
732 "Use a known id prefix ({KNOWN_ID_PREFIXES_HINT}) or move the file to the correct directory.",
733 )
734 } else {
735 format!(
736 "Move the file to `{}` or update the id prefix to `{}`.",
737 kind.directory_name(),
738 kind.id_prefix()
739 )
740 };
741 push_issue(
742 errors,
743 path,
744 format!(
745 "id prefix does not match directory (id={trimmed}, dir={})",
746 kind.directory_name()
747 ),
748 Some(suggestion),
749 );
750 }
751 }
752
753 let expected_file = format!("{trimmed}.md");
754 if path
755 .file_name()
756 .and_then(|s| s.to_str())
757 .is_some_and(|name| name != expected_file)
758 {
759 push_issue(
760 errors,
761 path,
762 format!("file name does not match id (expected {expected_file})"),
763 Some(format!(
764 "Rename the file to `{expected_file}` or update `id` to match the filename.",
765 )),
766 );
767 }
768}
769
770fn check_frontmatter_fields(
771 frontmatter: &Mapping,
772 kind: EntityKind,
773 path: &Path,
774 errors: &mut Vec<CheckIssue>,
775) {
776 check_frontmatter_type(frontmatter, kind, path, errors);
777 check_frontmatter_title(frontmatter, path, errors);
778 check_timestamp(frontmatter, "created_at", path, errors);
779 check_timestamp(frontmatter, "updated_at", path, errors);
780 check_tags_field(frontmatter, path, errors);
781 check_optional_string_field(frontmatter, "status", path, errors);
782 check_optional_string_field(frontmatter, "created_by", path, errors);
783 check_optional_string_field(frontmatter, "date", path, errors);
784}
785
786fn check_frontmatter_type(
787 frontmatter: &Mapping,
788 kind: EntityKind,
789 path: &Path,
790 errors: &mut Vec<CheckIssue>,
791) {
792 match frontmatter.get(Value::String("type".to_string())) {
793 Some(Value::String(value)) => {
794 let trimmed = value.trim();
795 if trimmed.is_empty() {
796 push_issue(
797 errors,
798 path,
799 "missing frontmatter type",
800 Some(format!("Add `type: {}` to frontmatter.", kind.as_str())),
801 );
802 return;
803 }
804
805 match trimmed.parse::<EntityKind>() {
806 Ok(parsed) => {
807 if parsed != kind {
808 push_issue(
809 errors,
810 path,
811 format!(
812 "frontmatter type does not match directory (type={trimmed}, dir={})",
813 kind.directory_name()
814 ),
815 Some(format!(
816 "Set `type` to `{}` or move the file to the correct directory.",
817 kind.as_str()
818 )),
819 );
820 }
821 }
822 Err(_) => {
823 push_issue(
824 errors,
825 path,
826 format!("unknown frontmatter type: {trimmed}"),
827 Some(format!(
828 "Set `type` to `{}` or move the file to the correct directory.",
829 kind.as_str()
830 )),
831 );
832 }
833 }
834 }
835 Some(_) => {
836 push_issue(
837 errors,
838 path,
839 "frontmatter type must be a string",
840 Some("Set `type` to a string like `issue`.".to_string()),
841 );
842 }
843 None => {
844 push_issue(
845 errors,
846 path,
847 "missing frontmatter type",
848 Some(format!("Add `type: {}` to frontmatter.", kind.as_str())),
849 );
850 }
851 }
852}
853
854fn check_frontmatter_title(frontmatter: &Mapping, path: &Path, errors: &mut Vec<CheckIssue>) {
855 match frontmatter.get(Value::String("title".to_string())) {
856 Some(Value::String(value)) => {
857 if value.trim().is_empty() {
858 push_issue(
859 errors,
860 path,
861 "missing or empty title",
862 Some("Add a non-empty `title` string.".to_string()),
863 );
864 }
865 }
866 Some(_) => {
867 push_issue(
868 errors,
869 path,
870 "frontmatter title must be a string",
871 Some("Set `title` to a string value.".to_string()),
872 );
873 }
874 None => {
875 push_issue(
876 errors,
877 path,
878 "missing or empty title",
879 Some("Add a non-empty `title` string.".to_string()),
880 );
881 }
882 }
883}
884
885fn check_timestamp(frontmatter: &Mapping, key: &str, path: &Path, errors: &mut Vec<CheckIssue>) {
886 match frontmatter.get(Value::String(key.to_string())) {
887 Some(Value::String(value)) => {
888 if DateTime::parse_from_rfc3339(value).is_err() {
889 push_issue(
890 errors,
891 path,
892 format!("{key} is not RFC3339"),
893 Some(format!(
894 "Set `{key}` to an RFC3339 timestamp, for example `2024-01-01T00:00:00Z`.",
895 )),
896 );
897 }
898 }
899 Some(_) => {
900 push_issue(
901 errors,
902 path,
903 format!("{key} must be a string"),
904 Some(format!("Set `{key}` to an RFC3339 string.")),
905 );
906 }
907 None => {
908 push_issue(
909 errors,
910 path,
911 format!("missing {key} timestamp"),
912 Some(format!(
913 "Add `{key}` in RFC3339, for example `2024-01-01T00:00:00Z`.",
914 )),
915 );
916 }
917 }
918}
919
920fn check_tags_field(frontmatter: &Mapping, path: &Path, errors: &mut Vec<CheckIssue>) {
921 let Some(value) = frontmatter.get(Value::String("tags".to_string())) else {
922 return;
923 };
924
925 let tags_ok = match value {
926 Value::Sequence(seq) => seq.iter().all(|item| matches!(item, Value::String(_))),
927 Value::String(_) => true,
928 _ => false,
929 };
930 if !tags_ok {
931 push_issue(
932 errors,
933 path,
934 "tags must be a string or list of strings",
935 Some("Use `tags: []` or `tags: [\"foo\", \"bar\"]`.".to_string()),
936 );
937 }
938}
939
940fn check_optional_string_field(
941 frontmatter: &Mapping,
942 key: &str,
943 path: &Path,
944 errors: &mut Vec<CheckIssue>,
945) {
946 if let Some(value) = frontmatter.get(Value::String(key.to_string()))
947 && !matches!(value, Value::String(_))
948 {
949 push_issue(
950 errors,
951 path,
952 format!("{key} must be a string"),
953 Some(format!("Set `{key}` to a string.")),
954 );
955 }
956}
957
958fn check_relationships(
959 paths: &IxchelPaths,
960 frontmatter: &Mapping,
961 path: &Path,
962 errors: &mut Vec<CheckIssue>,
963) {
964 for (rel, targets) in extract_relationships(frontmatter) {
965 for target in targets {
966 let Some(target_path) = paths.entity_path(&target) else {
967 push_issue(
968 errors,
969 path,
970 format!("unknown id prefix in {rel}: {target}"),
971 Some(format!(
972 "Use a known id prefix ({KNOWN_ID_PREFIXES_HINT}) in `{rel}`.",
973 )),
974 );
975 continue;
976 };
977
978 if !target_path.exists() {
979 let suggestion = format!(
980 "Create `{}` or remove `{rel}` -> `{target}`.",
981 target_path.display()
982 );
983 push_issue(
984 errors,
985 path,
986 format!("broken link {rel} -> {target}"),
987 Some(suggestion),
988 );
989 }
990 }
991 }
992}
993
994fn push_issue(
995 errors: &mut Vec<CheckIssue>,
996 path: &Path,
997 message: impl Into<String>,
998 suggestion: Option<String>,
999) {
1000 errors.push(CheckIssue {
1001 path: path.to_path_buf(),
1002 message: message.into(),
1003 suggestion,
1004 });
1005}
1006
1007fn parse_timestamp(frontmatter: &Mapping, key: &str) -> Option<DateTime<Utc>> {
1008 let raw = get_string(frontmatter, key)?;
1009 let parsed = DateTime::parse_from_rfc3339(&raw).ok()?;
1010 Some(parsed.with_timezone(&Utc))
1011}
1012
1013fn cmp_timestamp_desc(
1014 a: Option<&DateTime<Utc>>,
1015 b: Option<&DateTime<Utc>>,
1016 a_id: &str,
1017 b_id: &str,
1018) -> Ordering {
1019 match (a, b) {
1020 (Some(a_ts), Some(b_ts)) => b_ts.cmp(a_ts).then_with(|| a_id.cmp(b_id)),
1021 (Some(_), None) => Ordering::Less,
1022 (None, Some(_)) => Ordering::Greater,
1023 (None, None) => a_id.cmp(b_id),
1024 }
1025}
1026
1027struct ListEntry {
1028 summary: EntitySummary,
1029 sort_ts: Option<DateTime<Utc>>,
1030}
1031
1032fn default_actor() -> Option<String> {
1033 std::env::var("IXCHEL_ACTOR")
1034 .ok()
1035 .or_else(|| std::env::var("USER").ok())
1036 .or_else(|| std::env::var("USERNAME").ok())
1037 .map(|s| s.trim().to_string())
1038 .filter(|s| !s.is_empty())
1039}
1040
1041fn default_template(kind: EntityKind) -> String {
1042 match kind {
1043 EntityKind::Decision => "## Context\n\n_Why is this decision needed?_\n\n## Decision\n\n_What did we decide?_\n\n## Consequences\n\n_What are the implications?_\n".to_string(),
1044 EntityKind::Issue => "## Problem\n\n_What is broken or missing?_\n\n## Plan\n\n- [ ] _Add steps_\n".to_string(),
1045 EntityKind::Idea => "## Summary\n\n_Describe the idea._\n".to_string(),
1046 EntityKind::Report => "## Summary\n\n_What did we learn?_\n".to_string(),
1047 EntityKind::Source => "## Summary\n\n_What is this source?_\n".to_string(),
1048 EntityKind::Citation => "## Quote\n\n> _Paste the quote here._\n".to_string(),
1049 EntityKind::Agent => "## Notes\n\n_Agent description and preferences._\n".to_string(),
1050 EntityKind::Session => "## Notes\n\n_Session context._\n".to_string(),
1051 }
1052}
1053
1054fn ensure_project_gitignore(repo_root: &Path) -> Result<()> {
1055 let path = repo_root.join(".gitignore");
1056 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1057
1058 let has_data = existing.lines().any(|l| l.trim() == ".ixchel/data/");
1059 let has_models = existing.lines().any(|l| l.trim() == ".ixchel/models/");
1060 if has_data && has_models {
1061 return Ok(());
1062 }
1063
1064 let mut out = existing;
1065 if !out.ends_with('\n') && !out.is_empty() {
1066 out.push('\n');
1067 }
1068 if !out.is_empty() {
1069 out.push('\n');
1070 }
1071
1072 out.push_str("# Ixchel (rebuildable cache)\n");
1073 if !has_data {
1074 out.push_str(".ixchel/data/\n");
1075 }
1076 if !has_models {
1077 out.push_str(".ixchel/models/\n");
1078 }
1079
1080 std::fs::write(&path, out).with_context(|| format!("Failed to write {}", path.display()))?;
1081 Ok(())
1082}
1083
1084fn extract_relationships(frontmatter: &serde_yaml::Mapping) -> Vec<(String, Vec<String>)> {
1085 let mut rels = Vec::new();
1086
1087 for (key, value) in frontmatter {
1088 let serde_yaml::Value::String(key) = key else {
1089 continue;
1090 };
1091
1092 if METADATA_KEYS.contains(&key.as_str()) {
1093 continue;
1094 }
1095
1096 let targets = match value {
1097 serde_yaml::Value::Sequence(seq) => seq
1098 .iter()
1099 .filter_map(|v| match v {
1100 serde_yaml::Value::String(s) => Some(s.clone()),
1101 _ => None,
1102 })
1103 .collect::<Vec<_>>(),
1104 serde_yaml::Value::String(s) => vec![s.clone()],
1105 _ => Vec::new(),
1106 };
1107
1108 let targets = targets
1109 .into_iter()
1110 .filter(|t| looks_like_entity_id(t))
1111 .collect::<Vec<_>>();
1112
1113 if targets.is_empty() {
1114 continue;
1115 }
1116
1117 rels.push((key.clone(), targets));
1118 }
1119
1120 rels
1121}
1122
1123fn normalize_tag(tag: &str) -> Option<String> {
1124 let trimmed = tag.trim();
1125 if trimmed.is_empty() {
1126 None
1127 } else {
1128 Some(trimmed.to_string())
1129 }
1130}
1131
1132fn normalized_tags_vec(frontmatter: &Mapping) -> Vec<String> {
1133 let mut tags = Vec::new();
1134 let mut seen = BTreeSet::new();
1135 for tag in get_string_list(frontmatter, "tags") {
1136 if let Some(tag) = normalize_tag(&tag)
1137 && seen.insert(tag.clone())
1138 {
1139 tags.push(tag);
1140 }
1141 }
1142 tags
1143}
1144
1145fn id_hint(file_id: &str, kind: EntityKind) -> String {
1146 let trimmed = file_id.trim();
1147 if trimmed.is_empty() {
1148 return format!("{}-<hash>", kind.id_prefix());
1149 }
1150
1151 if ix_id::parse_id(trimmed).is_ok() {
1152 trimmed.to_string()
1153 } else {
1154 format!("{}-<hash>", kind.id_prefix())
1155 }
1156}