commit_wizard/engine/capabilities/config/
edit.rs1use 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}