Skip to main content

cgx_engine/docs/
prompts_index.rs

1//! Helper for `cgx docs prompts` — scan the vault for module notes whose
2//! `<!-- cgx-prompt -->` block hasn't been filled in yet.
3//!
4//! A "filled" note is one where the cgx-prompt markers have been replaced
5//! with prose (i.e., the markers no longer appear in the file).
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct PromptEntry {
11    pub note_path: PathBuf,
12    pub source_path: Option<String>,
13    pub byte_offset_begin: usize,
14    pub byte_offset_end: usize,
15    pub packet: String,
16}
17
18/// Walk the vault and return every note that still contains an unfilled packet.
19pub fn list_unfilled(vault_root: &Path) -> std::io::Result<Vec<PromptEntry>> {
20    let mut out = Vec::new();
21    walk(vault_root, &mut out)?;
22    Ok(out)
23}
24
25fn walk(dir: &Path, out: &mut Vec<PromptEntry>) -> std::io::Result<()> {
26    for entry in std::fs::read_dir(dir)? {
27        let entry = entry?;
28        let path = entry.path();
29        if path.is_dir() {
30            // Skip dot-folders (.obsidian, .git, etc.)
31            if path
32                .file_name()
33                .and_then(|n| n.to_str())
34                .map(|n| n.starts_with('.'))
35                .unwrap_or(false)
36            {
37                continue;
38            }
39            walk(&path, out)?;
40            continue;
41        }
42        if path.extension().and_then(|e| e.to_str()) != Some("md") {
43            continue;
44        }
45        let content = match std::fs::read_to_string(&path) {
46            Ok(c) => c,
47            Err(_) => continue,
48        };
49        if let Some((b, e, packet, source)) = find_packet(&content) {
50            out.push(PromptEntry {
51                note_path: path.clone(),
52                source_path: source,
53                byte_offset_begin: b,
54                byte_offset_end: e,
55                packet,
56            });
57        }
58    }
59    Ok(())
60}
61
62const BEGIN: &str = "<!-- cgx-prompt:begin -->";
63const END: &str = "<!-- cgx-prompt:end -->";
64
65fn find_packet(content: &str) -> Option<(usize, usize, String, Option<String>)> {
66    let begin = content.find(BEGIN)?;
67    let end_marker = content[begin..].find(END)? + begin;
68    let end = end_marker + END.len();
69    let packet = content[begin..end].to_string();
70    let source_path = find_frontmatter_field(content, "path");
71    Some((begin, end, packet, source_path))
72}
73
74fn find_frontmatter_field(content: &str, field: &str) -> Option<String> {
75    if !content.starts_with("---") {
76        return None;
77    }
78    let after = &content[3..];
79    let end = after.find("\n---")?;
80    let fm = &after[..end];
81    let prefix = format!("{}: ", field);
82    for line in fm.lines() {
83        if let Some(rest) = line.trim_start().strip_prefix(&prefix) {
84            let rest = rest.trim();
85            let rest = rest.trim_matches('"').trim_matches('\'');
86            return Some(rest.to_string());
87        }
88    }
89    None
90}
91
92/// Replace an unfilled packet in `content` with the given prose.
93/// Returns the new full file content, or None if no packet was found.
94pub fn fill_packet(content: &str, prose: &str) -> Option<String> {
95    let (b, e, _, _) = find_packet(content)?;
96    let mut out = String::with_capacity(content.len() + prose.len());
97    out.push_str(&content[..b]);
98    out.push_str(prose.trim_end());
99    out.push('\n');
100    out.push_str(&content[e..]);
101    Some(out)
102}