Skip to main content

ix_core/
repo.rs

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}