Skip to main content

code_system_graph_core/
manifest_edit.rs

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/// Preview of one constrained workspace-manifest mutation.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ManifestEdit {
14    /// Original content fingerprint used for concurrent-change detection.
15    pub original_hash: String,
16    /// Complete updated manifest content.
17    pub updated_source: String,
18    /// Human-readable operation summary.
19    pub summary: String,
20}
21
22/// Result of atomically committing a manifest edit.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ManifestWriteReport {
25    /// Canonical manifest path.
26    pub manifest_path: PathBuf,
27    /// Non-overwriting backup of the previous manifest.
28    pub backup_path: PathBuf,
29}
30
31/// Error returned by constrained manifest editing.
32#[derive(Debug, Error)]
33pub enum ManifestEditError {
34    /// Existing or generated manifest is invalid.
35    #[error(transparent)]
36    Manifest(#[from] ManifestError),
37    /// Alias cannot be represented safely as an unquoted YAML key.
38    #[error("repository alias `{0}` must contain only ASCII letters, digits, `_`, or `-`")]
39    InvalidAlias(String),
40    /// Alias already exists.
41    #[error("repository alias `{0}` already exists")]
42    DuplicateAlias(String),
43    /// Alias does not exist.
44    #[error("repository alias `{0}` does not exist")]
45    MissingAlias(String),
46    /// Manifest layout cannot be edited without rewriting unrelated content.
47    #[error("manifest layout is not surgically editable: {0}")]
48    UnsupportedLayout(String),
49    /// Filesystem operation failed.
50    #[error("manifest filesystem operation failed for `{path}`: {source}")]
51    Io {
52        /// Path involved in the operation.
53        path: PathBuf,
54        /// Underlying operating-system error.
55        source: std::io::Error,
56    },
57    /// Manifest changed after preview generation.
58    #[error("manifest `{0}` changed after preview; regenerate the preview")]
59    ConcurrentChange(PathBuf),
60}
61
62/// Previews adding a minimal repository entry while preserving unrelated bytes.
63///
64/// # Errors
65///
66/// Returns [`ManifestEditError`] for invalid aliases, duplicate aliases, unsupported layout, or
67/// invalid generated manifests.
68pub 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(&quoted_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
116/// Previews removing exactly one repository block while preserving unrelated bytes.
117///
118/// # Errors
119///
120/// Returns [`ManifestEditError`] when the alias is missing, layout is unsupported, or removal
121/// would make the manifest invalid.
122pub 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
154/// Previews appending one exact manual relationship while preserving unrelated bytes.
155///
156/// # Errors
157///
158/// Returns [`ManifestEditError`] when the declaration is invalid, duplicates an existing
159/// relationship, or the top-level manifest layout cannot be edited safely.
160pub 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
206/// Commits a preview with backup, concurrent-change detection, and atomic replacement.
207///
208/// # Errors
209///
210/// Returns [`ManifestEditError`] when the source changed, backup creation fails, or the atomic
211/// replacement cannot be committed.
212pub 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", &current) != 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}