Skip to main content

commit_wizard/engine/capabilities/config/
edit.rs

1use std::path::{Path, PathBuf};
2
3use toml::Value;
4
5use crate::engine::{
6    config::{ProjectConfig, StandardConfig},
7    error::{ErrorCode, Result},
8};
9
10use super::show::{ConfigTarget, resolve_config_path};
11
12#[derive(Debug, Clone)]
13pub struct ConfigGetInput<'a> {
14    pub cwd: &'a Path,
15    pub target: ConfigTarget,
16    pub explicit_path: Option<&'a Path>,
17    pub key: &'a str,
18}
19
20pub struct ConfigGetOutput {
21    pub path: PathBuf,
22    pub key: String,
23    pub value: Value,
24}
25
26#[derive(Debug, Clone)]
27pub struct ConfigSetInput<'a> {
28    pub cwd: &'a Path,
29    pub target: ConfigTarget,
30    pub key: &'a str,
31    pub value: &'a str,
32    pub dry_run: bool,
33    pub explicit_path: Option<&'a Path>,
34}
35
36pub struct ConfigSetOutput {
37    pub path: PathBuf,
38    pub key: String,
39    pub value: Value,
40}
41
42#[derive(Debug, Clone)]
43pub struct ConfigUnsetInput<'a> {
44    pub cwd: &'a Path,
45    pub target: ConfigTarget,
46    pub key: &'a str,
47    pub dry_run: bool,
48    pub explicit_path: Option<&'a Path>,
49}
50
51pub struct ConfigUnsetOutput {
52    pub path: PathBuf,
53    pub key: String,
54    pub removed: bool,
55}
56
57pub fn config_get(input: &ConfigGetInput<'_>) -> Result<ConfigGetOutput> {
58    ensure_supported_key(input.key)?;
59
60    let path = if let Some(explicit_path) = input.explicit_path {
61        explicit_path.to_path_buf()
62    } else {
63        resolve_config_path(input.target, input.cwd)?
64    };
65    if !path.exists() {
66        return Err(ErrorCode::ConfigUnreadable
67            .error()
68            .with_context("path", path.display().to_string())
69            .with_context("reason", "config file does not exist"));
70    }
71
72    let raw = std::fs::read_to_string(&path)?;
73    let doc = parse_doc(input.target, &raw)?;
74
75    let value = get_value(&doc, input.key).cloned().ok_or_else(|| {
76        ErrorCode::ConfigInvalid
77            .error()
78            .with_context("key", input.key)
79            .with_context("reason", "unknown key")
80    })?;
81
82    Ok(ConfigGetOutput {
83        path,
84        key: input.key.to_string(),
85        value,
86    })
87}
88
89pub fn config_set(input: &ConfigSetInput<'_>) -> Result<ConfigSetOutput> {
90    ensure_supported_key(input.key)?;
91
92    let path = if let Some(explicit_path) = input.explicit_path {
93        explicit_path.to_path_buf()
94    } else {
95        resolve_config_path(input.target, input.cwd)?
96    };
97    let raw = if path.exists() {
98        std::fs::read_to_string(&path)?
99    } else {
100        default_doc_toml(input.target)?
101    };
102
103    let mut doc = parse_doc(input.target, &raw)?;
104    let value = parse_value_for_key(input.key, input.value)?;
105
106    set_value(&mut doc, input.key, value.clone())?;
107    let validated = validate_doc(input.target, doc)?;
108    let rendered = toml::to_string_pretty(&validated).map_err(|err| {
109        ErrorCode::SerializationFailure
110            .error()
111            .with_context("error", err.to_string())
112    })?;
113
114    if !input.dry_run {
115        if let Some(parent) = path.parent()
116            && !parent.as_os_str().is_empty()
117        {
118            std::fs::create_dir_all(parent)?;
119        }
120        std::fs::write(&path, &rendered)?;
121    }
122
123    Ok(ConfigSetOutput {
124        path,
125        key: input.key.to_string(),
126        value,
127    })
128}
129
130pub fn config_unset(input: &ConfigUnsetInput<'_>) -> Result<ConfigUnsetOutput> {
131    ensure_supported_key(input.key)?;
132
133    let path = if let Some(explicit_path) = input.explicit_path {
134        explicit_path.to_path_buf()
135    } else {
136        resolve_config_path(input.target, input.cwd)?
137    };
138    if !path.exists() {
139        return Err(ErrorCode::ConfigUnreadable
140            .error()
141            .with_context("path", path.display().to_string())
142            .with_context("reason", "config file does not exist"));
143    }
144
145    let raw = std::fs::read_to_string(&path)?;
146    let mut doc = parse_doc(input.target, &raw)?;
147    let removed = unset_value(&mut doc, input.key)?;
148
149    let validated = validate_doc(input.target, doc)?;
150    let rendered = toml::to_string_pretty(&validated).map_err(|err| {
151        ErrorCode::SerializationFailure
152            .error()
153            .with_context("error", err.to_string())
154    })?;
155
156    if !input.dry_run {
157        std::fs::write(&path, &rendered)?;
158    }
159
160    Ok(ConfigUnsetOutput {
161        path,
162        key: input.key.to_string(),
163        removed,
164    })
165}
166
167fn ensure_supported_key(key: &str) -> Result<()> {
168    if is_supported_key(key) {
169        Ok(())
170    } else {
171        Err(ErrorCode::ConfigInvalid
172            .error()
173            .with_context("key", key)
174            .with_context("reason", "unsupported or unknown key"))
175    }
176}
177
178fn is_supported_key(key: &str) -> bool {
179    matches!(
180        key,
181        "commit.subject_max_length"
182            | "commit.ticket.required"
183            | "commit.ticket.pattern"
184            | "commit.ticket.header_format"
185            | "branch.remote"
186            | "release.enabled"
187            | "release.source_branch"
188            | "release.target_branch"
189            | "ai.enabled"
190            | "ai.provider"
191            | "changelog.output"
192            | "versioning.tag_prefix"
193    )
194}
195
196fn parse_doc(target: ConfigTarget, raw: &str) -> Result<Value> {
197    match target {
198        ConfigTarget::Project => {
199            let parsed = ProjectConfig::from_toml_str(raw)?;
200            toml::Value::try_from(parsed).map_err(|err| {
201                ErrorCode::SerializationFailure
202                    .error()
203                    .with_context("error", err.to_string())
204            })
205        }
206        ConfigTarget::Global => {
207            let parsed = StandardConfig::from_toml_str(raw)?;
208            toml::Value::try_from(parsed).map_err(|err| {
209                ErrorCode::SerializationFailure
210                    .error()
211                    .with_context("error", err.to_string())
212            })
213        }
214    }
215}
216
217fn validate_doc(target: ConfigTarget, value: Value) -> Result<Value> {
218    match target {
219        ConfigTarget::Project => {
220            let parsed: ProjectConfig = value.clone().try_into().map_err(|err| {
221                ErrorCode::ConfigInvalid
222                    .error()
223                    .with_context("error", err.to_string())
224            })?;
225            toml::Value::try_from(parsed).map_err(|err| {
226                ErrorCode::SerializationFailure
227                    .error()
228                    .with_context("error", err.to_string())
229            })
230        }
231        ConfigTarget::Global => {
232            let parsed: StandardConfig = value.clone().try_into().map_err(|err| {
233                ErrorCode::ConfigInvalid
234                    .error()
235                    .with_context("error", err.to_string())
236            })?;
237            toml::Value::try_from(parsed).map_err(|err| {
238                ErrorCode::SerializationFailure
239                    .error()
240                    .with_context("error", err.to_string())
241            })
242        }
243    }
244}
245
246fn default_doc_toml(target: ConfigTarget) -> Result<String> {
247    match target {
248        ConfigTarget::Project => {
249            let doc = ProjectConfig::minimal();
250            toml::to_string_pretty(&doc).map_err(|err| {
251                ErrorCode::SerializationFailure
252                    .error()
253                    .with_context("error", err.to_string())
254            })
255        }
256        ConfigTarget::Global => {
257            let doc = StandardConfig::minimal();
258            toml::to_string_pretty(&doc).map_err(|err| {
259                ErrorCode::SerializationFailure
260                    .error()
261                    .with_context("error", err.to_string())
262            })
263        }
264    }
265}
266
267fn get_value<'a>(root: &'a Value, key: &str) -> Option<&'a Value> {
268    let mut current = root;
269    for segment in key.split('.') {
270        current = current.get(segment)?;
271    }
272    Some(current)
273}
274
275fn set_value(root: &mut Value, key: &str, value: Value) -> Result<()> {
276    let mut current = root;
277    let mut segments = key.split('.').peekable();
278
279    while let Some(segment) = segments.next() {
280        let is_last = segments.peek().is_none();
281
282        if is_last {
283            let table = current.as_table_mut().ok_or_else(|| {
284                ErrorCode::ConfigInvalid
285                    .error()
286                    .with_context("key", key)
287                    .with_context("reason", "target parent is not a table")
288            })?;
289            table.insert(segment.to_string(), value);
290            return Ok(());
291        }
292
293        let table = current.as_table_mut().ok_or_else(|| {
294            ErrorCode::ConfigInvalid
295                .error()
296                .with_context("key", key)
297                .with_context("reason", "intermediate path is not a table")
298        })?;
299
300        current = table
301            .entry(segment.to_string())
302            .or_insert_with(|| Value::Table(Default::default()));
303    }
304
305    Err(ErrorCode::ConfigInvalid.error().with_context("key", key))
306}
307
308fn unset_value(root: &mut Value, key: &str) -> Result<bool> {
309    let mut current = root;
310    let mut segments = key.split('.').peekable();
311
312    while let Some(segment) = segments.next() {
313        let is_last = segments.peek().is_none();
314
315        if is_last {
316            let table = current.as_table_mut().ok_or_else(|| {
317                ErrorCode::ConfigInvalid
318                    .error()
319                    .with_context("key", key)
320                    .with_context("reason", "target parent is not a table")
321            })?;
322            return Ok(table.remove(segment).is_some());
323        }
324
325        current = match current.get_mut(segment) {
326            Some(next) => next,
327            None => return Ok(false),
328        };
329    }
330
331    Ok(false)
332}
333
334fn parse_value_for_key(key: &str, raw: &str) -> Result<Value> {
335    match key {
336        "commit.subject_max_length" => raw.parse::<i64>().map(Value::Integer).map_err(|_| {
337            ErrorCode::ConfigInvalid
338                .error()
339                .with_context("key", key)
340                .with_context("value", raw)
341                .with_context("reason", "expected integer")
342        }),
343        "commit.ticket.required" | "release.enabled" | "ai.enabled" => {
344            raw.parse::<bool>().map(Value::Boolean).map_err(|_| {
345                ErrorCode::ConfigInvalid
346                    .error()
347                    .with_context("key", key)
348                    .with_context("value", raw)
349                    .with_context("reason", "expected boolean")
350            })
351        }
352        "branch.remote"
353        | "commit.ticket.pattern"
354        | "commit.ticket.header_format"
355        | "release.source_branch"
356        | "release.target_branch"
357        | "ai.provider"
358        | "changelog.output"
359        | "versioning.tag_prefix" => Ok(Value::String(raw.to_string())),
360        _ => Err(ErrorCode::ConfigInvalid
361            .error()
362            .with_context("key", key)
363            .with_context("reason", "unsupported or unknown key")),
364    }
365}