Skip to main content

gobby_code/commands/codewiki/
io.rs

1use super::*;
2
3pub fn write_doc_set(out_dir: &Path, docs: &[(String, String)]) -> anyhow::Result<()> {
4    std::fs::create_dir_all(out_dir)?;
5    for (relative_path, content) in docs {
6        write_doc(out_dir, relative_path, content)?;
7    }
8    Ok(())
9}
10
11pub fn write_incremental_doc_set(
12    project_root: &Path,
13    out_dir: &Path,
14    docs: &[(String, String)],
15) -> anyhow::Result<Vec<String>> {
16    std::fs::create_dir_all(out_dir)?;
17    let previous = read_codewiki_meta(out_dir)?;
18    let mut next_docs = BTreeMap::new();
19    let mut generated_docs = Vec::new();
20
21    for (relative_path, content) in docs {
22        let doc_meta = CodewikiDocMeta {
23            source_hashes: source_hashes_for_doc(project_root, content)?,
24        };
25        let target = safe_doc_path(out_dir, relative_path)?;
26        let unchanged = target.exists()
27            && previous
28                .docs
29                .get(relative_path)
30                .is_some_and(|previous_meta| previous_meta == &doc_meta);
31
32        if !unchanged {
33            write_doc(out_dir, relative_path, content)?;
34            generated_docs.push(relative_path.clone());
35        }
36        next_docs.insert(relative_path.clone(), doc_meta);
37    }
38
39    for stale_path in previous
40        .docs
41        .keys()
42        .filter(|key| !next_docs.contains_key(*key))
43    {
44        let target = safe_doc_path(out_dir, stale_path)?;
45        reject_symlinked_doc_path(out_dir, &target)?;
46        match std::fs::remove_file(&target) {
47            Ok(()) => prune_empty_doc_dirs(out_dir, &target)?,
48            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
49            Err(err) => return Err(err.into()),
50        }
51    }
52
53    let meta = CodewikiMeta {
54        docs: next_docs,
55        generated_docs: generated_docs.clone(),
56    };
57    write_codewiki_meta(out_dir, &meta)?;
58    Ok(generated_docs)
59}
60
61pub(crate) fn write_doc(out_dir: &Path, relative_path: &str, content: &str) -> anyhow::Result<()> {
62    let target = safe_doc_path(out_dir, relative_path)?;
63    reject_symlinked_doc_path(out_dir, &target)?;
64    if let Some(parent) = target.parent() {
65        std::fs::create_dir_all(parent)?;
66    }
67    std::fs::write(target, content)?;
68    Ok(())
69}
70
71pub(crate) fn reject_symlinked_doc_path(out_dir: &Path, target: &Path) -> anyhow::Result<()> {
72    let relative = target.strip_prefix(out_dir)?;
73    let mut current = out_dir.to_path_buf();
74    for component in relative.components() {
75        current.push(component);
76        match std::fs::symlink_metadata(&current) {
77            Ok(metadata) if metadata.file_type().is_symlink() => {
78                anyhow::bail!(
79                    "refusing to follow symlinked codewiki path: {}",
80                    current.display()
81                );
82            }
83            Ok(_) => {}
84            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
85            Err(err) => return Err(err.into()),
86        }
87    }
88    Ok(())
89}
90
91pub(crate) fn prune_empty_doc_dirs(out_dir: &Path, target: &Path) -> anyhow::Result<()> {
92    let mut current = target.parent();
93    while let Some(dir) = current {
94        if dir == out_dir {
95            break;
96        }
97        match std::fs::remove_dir(dir) {
98            Ok(()) => current = dir.parent(),
99            Err(err)
100                if matches!(
101                    err.kind(),
102                    std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
103                ) =>
104            {
105                break;
106            }
107            Err(err) => return Err(err.into()),
108        }
109    }
110    Ok(())
111}
112
113pub(crate) fn read_codewiki_meta(out_dir: &Path) -> anyhow::Result<CodewikiMeta> {
114    let path = safe_doc_path(out_dir, CODEWIKI_META_PATH)?;
115    match std::fs::read_to_string(&path) {
116        Ok(raw) => Ok(serde_json::from_str(&raw)?),
117        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(CodewikiMeta::default()),
118        Err(err) => Err(err.into()),
119    }
120}
121
122pub(crate) fn write_codewiki_meta(out_dir: &Path, meta: &CodewikiMeta) -> anyhow::Result<()> {
123    let content = serde_json::to_string_pretty(meta)?;
124    write_doc(out_dir, CODEWIKI_META_PATH, &(content + "\n"))
125}
126
127pub(crate) fn source_hashes_for_doc(
128    project_root: &Path,
129    content: &str,
130) -> anyhow::Result<BTreeMap<String, String>> {
131    let mut hashes = BTreeMap::new();
132    let canonical_root = project_root
133        .canonicalize()
134        .map_err(|err| anyhow::anyhow!("failed to resolve codewiki project root: {err}"))?;
135    for file in source_files_from_frontmatter(content) {
136        let source_path = project_root.join(&file);
137        let canonical_source = source_path.canonicalize().map_err(|err| {
138            anyhow::anyhow!("failed to resolve codewiki source file {file}: {err}")
139        })?;
140        if !canonical_source.starts_with(&canonical_root) {
141            anyhow::bail!("codewiki source file {file} resolves outside project root");
142        }
143        let hash = hasher::file_content_hash(&canonical_source)
144            .map_err(|err| anyhow::anyhow!("failed to hash codewiki source file {file}: {err}"))?;
145        hashes.insert(file, hash);
146    }
147    Ok(hashes)
148}
149
150pub(crate) fn source_files_from_frontmatter(content: &str) -> BTreeSet<String> {
151    let mut files = BTreeSet::new();
152
153    let mut lines = content.lines();
154    if lines.next() != Some("---") {
155        return files;
156    }
157    let frontmatter = lines
158        .take_while(|line| *line != "---")
159        .collect::<Vec<_>>()
160        .join("\n");
161    let Ok(serde_yaml::Value::Mapping(frontmatter)) =
162        serde_yaml::from_str::<serde_yaml::Value>(&frontmatter)
163    else {
164        return files;
165    };
166
167    for key in ["source_files", "sources"] {
168        let key = serde_yaml::Value::String(key.to_string());
169        let Some(serde_yaml::Value::Sequence(sources)) = frontmatter.get(&key) else {
170            continue;
171        };
172        for source in sources {
173            let serde_yaml::Value::Mapping(source) = source else {
174                continue;
175            };
176            let file_key = serde_yaml::Value::String("file".to_string());
177            if let Some(serde_yaml::Value::String(file)) = source.get(&file_key) {
178                files.insert(file.clone());
179            }
180        }
181    }
182    files
183}
184
185#[cfg(test)]
186pub(crate) fn unquote_yaml_string(value: &str) -> Option<String> {
187    let value = value.trim();
188    let inner = value.strip_prefix('"')?.strip_suffix('"')?;
189    let mut out = String::new();
190    let mut chars = inner.chars();
191    while let Some(ch) = chars.next() {
192        if ch == '\\' {
193            out.push(match chars.next()? {
194                '0' => '\0',
195                'a' => '\u{0007}',
196                'b' => '\u{0008}',
197                't' => '\t',
198                'n' => '\n',
199                'v' => '\u{000b}',
200                'f' => '\u{000c}',
201                'r' => '\r',
202                'e' => '\u{001b}',
203                '"' => '"',
204                '/' => '/',
205                '\\' => '\\',
206                'x' => decode_hex_escape(&mut chars, 2)?,
207                'u' => decode_hex_escape(&mut chars, 4)?,
208                'U' => decode_hex_escape(&mut chars, 8)?,
209                _ => return None,
210            });
211        } else {
212            out.push(ch);
213        }
214    }
215    Some(out)
216}
217
218#[cfg(test)]
219fn decode_hex_escape(chars: &mut std::str::Chars<'_>, digits: usize) -> Option<char> {
220    let mut value = 0_u32;
221    for _ in 0..digits {
222        value = value.checked_mul(16)?;
223        value = value.checked_add(chars.next()?.to_digit(16)?)?;
224    }
225    char::from_u32(value)
226}
227
228pub(crate) fn safe_doc_path(out_dir: &Path, relative_path: &str) -> anyhow::Result<PathBuf> {
229    let path = Path::new(relative_path);
230    if path.is_absolute()
231        || path
232            .components()
233            .any(|component| matches!(component, std::path::Component::ParentDir))
234    {
235        anyhow::bail!("refusing to write unsafe codewiki path: {relative_path}");
236    }
237    Ok(out_dir.join(path))
238}