gobby_code/commands/codewiki/
io.rs1use 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(¤t) {
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}