1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use atomic_write_file::AtomicWriteFile;
6use code_system_graph_model::stable_id;
7use thiserror::Error;
8
9use crate::{ManifestError, ManualLinkConfig, parse_manifest};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ManifestEdit {
14 pub original_hash: String,
16 pub updated_source: String,
18 pub summary: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ManifestWriteReport {
25 pub manifest_path: PathBuf,
27 pub backup_path: PathBuf,
29}
30
31#[derive(Debug, Error)]
33pub enum ManifestEditError {
34 #[error(transparent)]
36 Manifest(#[from] ManifestError),
37 #[error("repository alias `{0}` must contain only ASCII letters, digits, `_`, or `-`")]
39 InvalidAlias(String),
40 #[error("repository alias `{0}` already exists")]
42 DuplicateAlias(String),
43 #[error("repository alias `{0}` does not exist")]
45 MissingAlias(String),
46 #[error("manifest layout is not surgically editable: {0}")]
48 UnsupportedLayout(String),
49 #[error("manifest filesystem operation failed for `{path}`: {source}")]
51 Io {
52 path: PathBuf,
54 source: std::io::Error,
56 },
57 #[error("manifest `{0}` changed after preview; regenerate the preview")]
59 ConcurrentChange(PathBuf),
60}
61
62pub fn preview_add_repository(
69 source: &str,
70 alias: &str,
71 repository_path: &str,
72) -> Result<ManifestEdit, ManifestEditError> {
73 validate_alias(alias)?;
74 let manifest = parse_manifest(source)?;
75 if manifest.repos.contains_key(alias) {
76 return Err(ManifestEditError::DuplicateAlias(alias.to_owned()));
77 }
78 let lines = source.split_inclusive('\n').collect::<Vec<_>>();
79 let repos_index = find_repos_line(&lines)?;
80 let insertion_index = find_repos_end(&lines, repos_index);
81 let insertion_offset = lines[..insertion_index]
82 .iter()
83 .map(|line| line.len())
84 .sum::<usize>();
85 let newline = if source.contains("\r\n") {
86 "\r\n"
87 } else {
88 "\n"
89 };
90 let quoted_path =
91 serde_json::to_string(repository_path).map_err(|source| ManifestEditError::Io {
92 path: PathBuf::from("<repository-path>"),
93 source: std::io::Error::other(source),
94 })?;
95 let mut updated = String::with_capacity(source.len() + alias.len() + quoted_path.len() + 16);
96 updated.push_str(&source[..insertion_offset]);
97 if !updated.is_empty() && !updated.ends_with('\n') {
98 updated.push_str(newline);
99 }
100 updated.push_str(" ");
101 updated.push_str(alias);
102 updated.push(':');
103 updated.push_str(newline);
104 updated.push_str(" path: ");
105 updated.push_str("ed_path);
106 updated.push_str(newline);
107 updated.push_str(&source[insertion_offset..]);
108 parse_manifest(&updated)?;
109 Ok(ManifestEdit {
110 original_hash: stable_id("manifest-source", source),
111 updated_source: updated,
112 summary: format!("add repository `{alias}` at `{repository_path}`"),
113 })
114}
115
116pub fn preview_remove_repository(
123 source: &str,
124 alias: &str,
125) -> Result<ManifestEdit, ManifestEditError> {
126 validate_alias(alias)?;
127 let manifest = parse_manifest(source)?;
128 if !manifest.repos.contains_key(alias) {
129 return Err(ManifestEditError::MissingAlias(alias.to_owned()));
130 }
131 let lines = source.split_inclusive('\n').collect::<Vec<_>>();
132 let repos_index = find_repos_line(&lines)?;
133 let alias_index = find_alias_line(&lines, repos_index, alias)?;
134 let end_index = find_repository_end(&lines, alias_index);
135 let start_offset = lines[..alias_index]
136 .iter()
137 .map(|line| line.len())
138 .sum::<usize>();
139 let end_offset = lines[..end_index]
140 .iter()
141 .map(|line| line.len())
142 .sum::<usize>();
143 let mut updated = String::with_capacity(source.len() - (end_offset - start_offset));
144 updated.push_str(&source[..start_offset]);
145 updated.push_str(&source[end_offset..]);
146 parse_manifest(&updated)?;
147 Ok(ManifestEdit {
148 original_hash: stable_id("manifest-source", source),
149 updated_source: updated,
150 summary: format!("remove repository `{alias}`"),
151 })
152}
153
154pub fn preview_add_manual_link(
161 source: &str,
162 link: &ManualLinkConfig,
163) -> Result<ManifestEdit, ManifestEditError> {
164 let mut manifest = parse_manifest(source)?;
165 manifest.manual_links.push(link.clone());
166 crate::validate_manual_links(&manifest.manual_links)?;
167
168 let lines = source.split_inclusive('\n').collect::<Vec<_>>();
169 let manual_links_index = lines
170 .iter()
171 .position(|line| line_content(line) == "manualLinks:");
172 let insertion_index =
173 manual_links_index.map_or(lines.len(), |index| find_section_end(&lines, index));
174 let insertion_offset = lines[..insertion_index]
175 .iter()
176 .map(|line| line.len())
177 .sum::<usize>();
178 let newline = if source.contains("\r\n") {
179 "\r\n"
180 } else {
181 "\n"
182 };
183 let rendered = render_manual_link(link, newline)?;
184 let mut updated = String::with_capacity(source.len() + rendered.len() + 24);
185 updated.push_str(&source[..insertion_offset]);
186 if !updated.is_empty() && !updated.ends_with('\n') {
187 updated.push_str(newline);
188 }
189 if manual_links_index.is_none() {
190 updated.push_str("manualLinks:");
191 updated.push_str(newline);
192 }
193 updated.push_str(&rendered);
194 updated.push_str(&source[insertion_offset..]);
195 parse_manifest(&updated)?;
196 Ok(ManifestEdit {
197 original_hash: stable_id("manifest-source", source),
198 updated_source: updated,
199 summary: format!(
200 "add manual relationship `{}` -> `{}` ({:?})",
201 link.from, link.to, link.relation
202 ),
203 })
204}
205
206pub fn commit_manifest_edit(
213 manifest_path: &Path,
214 edit: &ManifestEdit,
215) -> Result<ManifestWriteReport, ManifestEditError> {
216 let canonical = fs::canonicalize(manifest_path).map_err(|source| ManifestEditError::Io {
217 path: manifest_path.to_path_buf(),
218 source,
219 })?;
220 let current = fs::read_to_string(&canonical).map_err(|source| ManifestEditError::Io {
221 path: canonical.clone(),
222 source,
223 })?;
224 if stable_id("manifest-source", ¤t) != edit.original_hash {
225 return Err(ManifestEditError::ConcurrentChange(canonical));
226 }
227 parse_manifest(&edit.updated_source)?;
228 let backup_path = next_backup_path(&canonical)?;
229 copy_new_file(&canonical, &backup_path)?;
230 let mut destination =
231 AtomicWriteFile::open(&canonical).map_err(|source| ManifestEditError::Io {
232 path: canonical.clone(),
233 source,
234 })?;
235 destination
236 .write_all(edit.updated_source.as_bytes())
237 .and_then(|()| destination.sync_all())
238 .map_err(|source| ManifestEditError::Io {
239 path: canonical.clone(),
240 source,
241 })?;
242 destination
243 .commit()
244 .map_err(|source| ManifestEditError::Io {
245 path: canonical.clone(),
246 source,
247 })?;
248 Ok(ManifestWriteReport {
249 manifest_path: canonical,
250 backup_path,
251 })
252}
253
254fn validate_alias(alias: &str) -> Result<(), ManifestEditError> {
255 let valid = !alias.is_empty()
256 && alias
257 .bytes()
258 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
259 if !valid {
260 return Err(ManifestEditError::InvalidAlias(alias.to_owned()));
261 }
262 Ok(())
263}
264
265fn find_repos_line(lines: &[&str]) -> Result<usize, ManifestEditError> {
266 lines
267 .iter()
268 .position(|line| line_content(line) == "repos:")
269 .ok_or_else(|| ManifestEditError::UnsupportedLayout("top-level `repos:` is missing".into()))
270}
271
272fn find_repos_end(lines: &[&str], repos_index: usize) -> usize {
273 find_section_end(lines, repos_index)
274}
275
276fn find_section_end(lines: &[&str], section_index: usize) -> usize {
277 lines
278 .iter()
279 .enumerate()
280 .skip(section_index + 1)
281 .find(|(_, line)| is_top_level_content(line))
282 .map_or(lines.len(), |(index, _)| index)
283}
284
285fn find_alias_line(
286 lines: &[&str],
287 repos_index: usize,
288 alias: &str,
289) -> Result<usize, ManifestEditError> {
290 let unquoted = format!(" {alias}:");
291 let quoted_alias = serde_json::to_string(alias).map_err(|source| ManifestEditError::Io {
292 path: PathBuf::from("<repository-alias>"),
293 source: std::io::Error::other(source),
294 })?;
295 let quoted = format!(" {quoted_alias}:");
296 let repos_end = find_repos_end(lines, repos_index);
297 lines[repos_index + 1..repos_end]
298 .iter()
299 .position(|line| {
300 let content = line_content(line);
301 content == unquoted || content == quoted
302 })
303 .map(|relative| repos_index + 1 + relative)
304 .ok_or_else(|| {
305 ManifestEditError::UnsupportedLayout(format!(
306 "repository `{alias}` key could not be located"
307 ))
308 })
309}
310
311fn find_repository_end(lines: &[&str], alias_index: usize) -> usize {
312 lines
313 .iter()
314 .enumerate()
315 .skip(alias_index + 1)
316 .find(|(_, line)| {
317 let content = line_content(line);
318 !content.is_empty()
319 && !content.trim_start().starts_with('#')
320 && leading_spaces(content) <= 2
321 })
322 .map_or(lines.len(), |(index, _)| index)
323}
324
325fn is_top_level_content(line: &str) -> bool {
326 let content = line_content(line);
327 !content.is_empty() && !content.starts_with(char::is_whitespace) && !content.starts_with('#')
328}
329
330fn line_content(line: &str) -> &str {
331 let line = line.strip_suffix('\n').unwrap_or(line);
332 line.strip_suffix('\r').unwrap_or(line)
333}
334
335fn leading_spaces(line: &str) -> usize {
336 line.bytes().take_while(|byte| *byte == b' ').count()
337}
338
339fn render_manual_link(link: &ManualLinkConfig, newline: &str) -> Result<String, ManifestEditError> {
340 let yaml = serde_saphyr::to_string(link).map_err(|source| ManifestEditError::Io {
341 path: PathBuf::from("<manual-link>"),
342 source: std::io::Error::other(source),
343 })?;
344 let mut rendered = String::new();
345 for (index, line) in yaml.lines().enumerate() {
346 if index == 0 {
347 rendered.push_str(" - ");
348 } else {
349 rendered.push_str(" ");
350 }
351 rendered.push_str(line);
352 rendered.push_str(newline);
353 }
354 Ok(rendered)
355}
356
357fn next_backup_path(manifest_path: &Path) -> Result<PathBuf, ManifestEditError> {
358 let extension = manifest_path
359 .extension()
360 .map(|value| value.to_string_lossy().into_owned());
361 for sequence in 0..10_000_u32 {
362 let suffix = if sequence == 0 {
363 "pre-edit.backup".to_owned()
364 } else {
365 format!("pre-edit.{sequence}.backup")
366 };
367 let mut candidate = manifest_path.to_path_buf();
368 candidate.set_extension(
369 extension
370 .as_ref()
371 .map_or_else(|| suffix.clone(), |value| format!("{value}.{suffix}")),
372 );
373 if !candidate.exists() {
374 return Ok(candidate);
375 }
376 }
377 Err(ManifestEditError::Io {
378 path: manifest_path.to_path_buf(),
379 source: std::io::Error::new(
380 std::io::ErrorKind::AlreadyExists,
381 "no available manifest backup filename",
382 ),
383 })
384}
385
386fn copy_new_file(source: &Path, destination: &Path) -> Result<(), ManifestEditError> {
387 let mut input = fs::File::open(source).map_err(|error| ManifestEditError::Io {
388 path: source.to_path_buf(),
389 source: error,
390 })?;
391 let mut output = OpenOptions::new()
392 .write(true)
393 .create_new(true)
394 .open(destination)
395 .map_err(|source| ManifestEditError::Io {
396 path: destination.to_path_buf(),
397 source,
398 })?;
399 std::io::copy(&mut input, &mut output)
400 .and_then(|_| output.sync_all())
401 .map_err(|source| ManifestEditError::Io {
402 path: destination.to_path_buf(),
403 source,
404 })?;
405 Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410 use code_system_graph_model::EdgeKind;
411
412 use super::{
413 ManifestEditError, commit_manifest_edit, preview_add_manual_link, preview_add_repository, preview_remove_repository
414 };
415 use crate::ManualLinkConfig;
416
417 const MANIFEST: &str = r"# leading comment
418version: 1
419name: demo
420repos:
421 api:
422 path: api
423 openapi: openapi.yaml
424
425allowedRoots:
426 - .
427";
428
429 #[test]
430 fn add_should_preserve_unrelated_manifest_bytes() {
431 let result = preview_add_repository(MANIFEST, "worker", "../worker");
432
433 assert!(matches!(
434 result,
435 Ok(edit)
436 if edit.updated_source.starts_with("# leading comment\n")
437 && edit.updated_source.contains(" worker:\n path: \"../worker\"\n")
438 && edit.updated_source.ends_with("allowedRoots:\n - .\n")
439 ));
440 }
441
442 #[test]
443 fn remove_should_delete_only_selected_repository_block() {
444 let source = MANIFEST.replace(" api:", " web:\n path: web\n api:");
445 let result = preview_remove_repository(&source, "api");
446
447 assert!(matches!(
448 result,
449 Ok(edit)
450 if edit.updated_source.contains(" web:\n path: web\n")
451 && !edit.updated_source.contains(" api:")
452 && edit.updated_source.contains("allowedRoots:")
453 ));
454 }
455
456 #[test]
457 fn commit_should_backup_and_detect_concurrent_changes() -> Result<(), Box<dyn std::error::Error>>
458 {
459 let temporary = tempfile::tempdir()?;
460 let manifest_path = temporary.path().join("code-system-graph.yaml");
461 std::fs::write(&manifest_path, MANIFEST)?;
462 let edit = preview_add_repository(MANIFEST, "worker", "worker")?;
463 let report = commit_manifest_edit(&manifest_path, &edit)?;
464 let backup = std::fs::read_to_string(report.backup_path)?;
465 let written = std::fs::read_to_string(&manifest_path)?;
466 std::fs::write(&manifest_path, format!("{MANIFEST}# concurrent change\n"))?;
467 let stale_result = commit_manifest_edit(&manifest_path, &edit);
468
469 assert_eq!(
470 (
471 backup == MANIFEST,
472 written.contains(" worker:"),
473 matches!(stale_result, Err(ManifestEditError::ConcurrentChange(_))),
474 ),
475 (true, true, true)
476 );
477 Ok(())
478 }
479
480 #[test]
481 fn add_manual_link_should_preserve_unrelated_manifest_bytes() {
482 let link = ManualLinkConfig {
483 from: "service:web".to_owned(),
484 to: "service:api".to_owned(),
485 relation: EdgeKind::Consumes,
486 contract: Some("POST /orders".to_owned()),
487 reason: "Manual checkout boundary".to_owned(),
488 suppress: false,
489 };
490
491 let result = preview_add_manual_link(MANIFEST, &link);
492
493 assert!(matches!(
494 result,
495 Ok(edit)
496 if edit.updated_source.starts_with("# leading comment\n")
497 && edit.updated_source.contains("manualLinks:\n")
498 && edit.updated_source.contains(" - from: service:web\n")
499 && edit.updated_source.ends_with(" suppress: false\n")
500 ));
501 }
502
503 #[test]
504 fn add_manual_link_should_append_to_existing_section() {
505 let existing = format!(
506 "{MANIFEST}manualLinks:\n - from: service:web\n to: service:api\n relation: consumes\n contract: null\n reason: Existing\n suppress: false\n"
507 );
508 let link = ManualLinkConfig {
509 from: "service:worker".to_owned(),
510 to: "service:api".to_owned(),
511 relation: EdgeKind::Consumes,
512 contract: None,
513 reason: "Explicit worker dependency".to_owned(),
514 suppress: false,
515 };
516
517 let result = preview_add_manual_link(&existing, &link);
518
519 assert!(matches!(
520 result,
521 Ok(edit)
522 if edit.updated_source.matches("manualLinks:").count() == 1
523 && edit.updated_source.contains(" - from: service:worker\n")
524 ));
525 }
526}