Skip to main content

systemprompt_cli/commands/cloud/profile/
edit_document.rs

1//! The raw profile document `cloud profile edit` writes back.
2//!
3//! Prompts and flags edit the interpolated [`Profile`] (so the operator sees
4//! real values), but saving that struct would materialise every `${VAR}`
5//! placeholder, collapse `${VAR:-default}` to its default and replace
6//! relative paths with resolved ones. [`ProfileDocument`] instead holds the
7//! file as authored and receives only the leaves the edit changed; the result
8//! is validated by parsing a separately interpolated copy before it is
9//! written.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use serde_yaml::Value;
18use systemprompt_manifest::Profile;
19
20use crate::commands::admin::config::config_section::read_yaml_file;
21
22/// The authored YAML of one profile, edited by difference.
23#[derive(Debug)]
24pub struct ProfileDocument {
25    path: PathBuf,
26    raw: Value,
27}
28
29impl ProfileDocument {
30    pub fn open(path: &Path) -> Result<Self> {
31        Ok(Self {
32            path: path.to_path_buf(),
33            raw: read_yaml_file(path)?,
34        })
35    }
36
37    pub fn apply_changes(&mut self, before: &Profile, after: &Profile) -> Result<()> {
38        let before = serde_yaml::to_value(before).context("Failed to serialize profile")?;
39        let after = serde_yaml::to_value(after).context("Failed to serialize profile")?;
40        merge_changed_leaves(&mut self.raw, &before, &after);
41        Ok(())
42    }
43
44    pub fn save(&self) -> Result<()> {
45        let content = serde_yaml::to_string(&self.raw).context("Failed to serialize profile")?;
46        Profile::from_yaml(&content, &self.path)
47            .with_context(|| format!("Edited profile is invalid: {}", self.path.display()))?;
48        std::fs::write(&self.path, content)
49            .with_context(|| format!("Failed to write {}", self.path.display()))
50    }
51}
52
53fn merge_changed_leaves(raw: &mut Value, before: &Value, after: &Value) {
54    let (Some(before_map), Some(after_map), Some(raw_map)) = (
55        before.as_mapping(),
56        after.as_mapping(),
57        raw.as_mapping_mut(),
58    ) else {
59        if before != after {
60            *raw = after.clone();
61        }
62        return;
63    };
64
65    for (key, after_value) in after_map {
66        let Some(before_value) = before_map.get(key) else {
67            raw_map.insert(key.clone(), after_value.clone());
68            continue;
69        };
70        if before_value == after_value {
71            continue;
72        }
73        if let Some(slot) = raw_map.get_mut(key) {
74            merge_changed_leaves(slot, before_value, after_value);
75        } else {
76            raw_map.insert(key.clone(), after_value.clone());
77        }
78    }
79    for key in before_map.keys() {
80        if !after_map.contains_key(key) {
81            raw_map.remove(key);
82        }
83    }
84}