1use std::fs;
2use std::path::{Path, PathBuf};
3
4use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value, value};
5
6use crate::config::{CONFIG_FILE_NAME, config_path_for_root, load_scope_config};
7use crate::env::ResolvedRoots;
8use crate::model::{
9 AddDocumentAction, AddDocumentReport, ConfigDocumentEntry, ConfigLoadError, Context,
10 DocumentWhen, Scope,
11};
12
13const DOCUMENT_KEY: &str = "document";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct AddDocumentRequest {
17 pub target: Scope,
18 pub context: Context,
19 pub scope: Scope,
20 pub path: PathBuf,
21 pub required: bool,
22 pub when: DocumentWhen,
23 pub notes: Option<String>,
24}
25
26impl AddDocumentRequest {
27 fn into_config_entry(self, config_path: &Path) -> Result<ConfigDocumentEntry, ConfigLoadError> {
28 let path = normalize_requested_path(&self.path, config_path)?;
29 Ok(ConfigDocumentEntry {
30 context: self.context,
31 scope: self.scope,
32 path,
33 required: self.required,
34 when: self.when,
35 notes: self.notes,
36 })
37 }
38}
39
40pub fn upsert_document(
41 roots: &ResolvedRoots,
42 request: AddDocumentRequest,
43) -> Result<AddDocumentReport, ConfigLoadError> {
44 let target_root = match request.target {
45 Scope::Home => roots.agent_home.as_path(),
46 Scope::Project => roots.project_path.as_path(),
47 };
48 upsert_document_at_root(target_root, request)
49}
50
51pub fn upsert_document_at_root(
52 target_root: &Path,
53 request: AddDocumentRequest,
54) -> Result<AddDocumentReport, ConfigLoadError> {
55 let target = request.target;
56 let config_path = config_path_for_root(target_root);
57 let entry = request.into_config_entry(&config_path)?;
58
59 if config_path.exists() {
61 let _ = load_scope_config(target, target_root)?;
62 }
63
64 let (mut document, created_config) = load_document(&config_path)?;
65
66 let (action, document_count) = {
67 let documents = documents_array_mut(&mut document, &config_path)?;
68 let action = upsert_documents(documents, &entry, &config_path)?;
69 (action, documents.len())
70 };
71
72 write_document(&config_path, &document)?;
73
74 Ok(AddDocumentReport {
75 target,
76 target_root: target_root.to_path_buf(),
77 config_path,
78 created_config,
79 action,
80 entry,
81 document_count,
82 })
83}
84
85fn load_document(config_path: &Path) -> Result<(DocumentMut, bool), ConfigLoadError> {
86 if !config_path.exists() {
87 return Ok((DocumentMut::new(), true));
88 }
89
90 let raw = fs::read_to_string(config_path).map_err(|err| {
91 ConfigLoadError::io(
92 config_path.to_path_buf(),
93 format!("failed to read {}: {err}", CONFIG_FILE_NAME),
94 )
95 })?;
96
97 let document = raw.parse::<DocumentMut>().map_err(|err| {
98 ConfigLoadError::parse(
99 config_path.to_path_buf(),
100 format!("invalid TOML in {}: {err}", CONFIG_FILE_NAME),
101 None,
102 )
103 })?;
104
105 Ok((document, false))
106}
107
108fn documents_array_mut<'a>(
109 document: &'a mut DocumentMut,
110 config_path: &Path,
111) -> Result<&'a mut ArrayOfTables, ConfigLoadError> {
112 if document.get(DOCUMENT_KEY).is_none() {
113 document[DOCUMENT_KEY] = Item::ArrayOfTables(ArrayOfTables::new());
114 }
115
116 document
117 .get_mut(DOCUMENT_KEY)
118 .and_then(Item::as_array_of_tables_mut)
119 .ok_or_else(|| {
120 ConfigLoadError::validation_root(
121 config_path.to_path_buf(),
122 DOCUMENT_KEY,
123 "key `document` must be an array of [[document]] tables",
124 )
125 })
126}
127
128fn upsert_documents(
129 documents: &mut ArrayOfTables,
130 incoming: &ConfigDocumentEntry,
131 config_path: &Path,
132) -> Result<AddDocumentAction, ConfigLoadError> {
133 let incoming_path = path_to_utf8(&incoming.path, config_path)?;
134
135 let mut matching_indices = Vec::new();
136 for (index, table) in documents.iter().enumerate() {
137 if table_matches(table, incoming, &incoming_path) {
138 matching_indices.push(index);
139 }
140 }
141
142 if matching_indices.is_empty() {
143 let mut table = Table::new();
144 apply_entry_to_table(&mut table, incoming, &incoming_path);
145 documents.push(table);
146 return Ok(AddDocumentAction::Inserted);
147 }
148
149 let mut replace_index = *matching_indices.last().expect("matching index exists");
150
151 for index in matching_indices.into_iter().rev() {
152 if index != replace_index {
153 documents.remove(index);
154 if index < replace_index {
155 replace_index -= 1;
156 }
157 }
158 }
159
160 let table = documents
161 .get_mut(replace_index)
162 .expect("replace index should remain valid");
163 apply_entry_to_table(table, incoming, &incoming_path);
164 Ok(AddDocumentAction::Updated)
165}
166
167fn table_matches(table: &Table, incoming: &ConfigDocumentEntry, incoming_path: &str) -> bool {
168 let context = table.get("context").and_then(Item::as_str);
169 let scope = table.get("scope").and_then(Item::as_str);
170 let path = table.get("path").and_then(Item::as_str).map(str::trim);
171
172 context == Some(incoming.context.as_str())
173 && scope == Some(incoming.scope.as_str())
174 && path == Some(incoming_path)
175}
176
177fn apply_entry_to_table(table: &mut Table, incoming: &ConfigDocumentEntry, incoming_path: &str) {
178 set_string_field(table, "context", incoming.context.as_str());
179 set_string_field(table, "scope", incoming.scope.as_str());
180 set_string_field(table, "path", incoming_path);
181 set_bool_field(table, "required", incoming.required);
182 set_string_field(table, "when", incoming.when.as_str());
183
184 if let Some(notes) = incoming.notes.as_deref() {
185 set_string_field(table, "notes", notes);
186 } else {
187 table.remove("notes");
188 }
189}
190
191fn set_string_field(table: &mut Table, key: &str, field_value: &str) {
192 if preserve_existing_value_decor(table, key, Value::from(field_value)) {
193 return;
194 }
195 table[key] = value(field_value);
196}
197
198fn set_bool_field(table: &mut Table, key: &str, field_value: bool) {
199 if preserve_existing_value_decor(table, key, Value::from(field_value)) {
200 return;
201 }
202 table[key] = value(field_value);
203}
204
205fn preserve_existing_value_decor(table: &mut Table, key: &str, field_value: Value) -> bool {
206 let Some(existing_item) = table.get_mut(key) else {
207 return false;
208 };
209 let Some(existing_value) = existing_item.as_value_mut() else {
210 return false;
211 };
212
213 let existing_decor = existing_value.decor().clone();
214 *existing_value = field_value;
215 *existing_value.decor_mut() = existing_decor;
216 true
217}
218
219fn normalize_requested_path(path: &Path, config_path: &Path) -> Result<PathBuf, ConfigLoadError> {
220 let Some(raw_path) = path.to_str() else {
221 return Err(ConfigLoadError::validation_root(
222 config_path.to_path_buf(),
223 "path",
224 "path must be valid UTF-8 for TOML serialization",
225 ));
226 };
227
228 let trimmed = raw_path.trim();
229 if trimmed.is_empty() {
230 return Err(ConfigLoadError::validation_root(
231 config_path.to_path_buf(),
232 "path",
233 "path cannot be empty",
234 ));
235 }
236
237 Ok(PathBuf::from(trimmed))
238}
239
240fn write_document(config_path: &Path, document: &DocumentMut) -> Result<(), ConfigLoadError> {
241 if let Some(parent) = config_path.parent() {
242 fs::create_dir_all(parent).map_err(|err| {
243 ConfigLoadError::io(
244 config_path.to_path_buf(),
245 format!(
246 "failed to create parent directory for {}: {err}",
247 CONFIG_FILE_NAME
248 ),
249 )
250 })?;
251 }
252
253 let mut body = document.to_string();
254 if !body.is_empty() && !body.ends_with('\n') {
255 body.push('\n');
256 }
257
258 fs::write(config_path, body).map_err(|err| {
259 ConfigLoadError::io(
260 config_path.to_path_buf(),
261 format!("failed to write {}: {err}", CONFIG_FILE_NAME),
262 )
263 })
264}
265
266fn path_to_utf8(path: &Path, config_path: &Path) -> Result<String, ConfigLoadError> {
267 path.to_str().map(ToString::to_string).ok_or_else(|| {
268 ConfigLoadError::validation_root(
269 config_path.to_path_buf(),
270 "path",
271 "path must be valid UTF-8 for TOML serialization",
272 )
273 })
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 use crate::config::{CONFIG_FILE_NAME, load_scope_config};
281 use crate::model::ConfigErrorKind;
282 use tempfile::TempDir;
283
284 fn roots(home: &TempDir, project: &TempDir) -> ResolvedRoots {
285 ResolvedRoots {
286 agent_home: home.path().to_path_buf(),
287 project_path: project.path().to_path_buf(),
288 is_linked_worktree: false,
289 git_common_dir: None,
290 primary_worktree_path: None,
291 }
292 }
293
294 #[test]
295 fn upsert_document_creates_missing_target_config_and_persists_entry() {
296 let home = TempDir::new().expect("create home tempdir");
297 let project = TempDir::new().expect("create project tempdir");
298 let roots = roots(&home, &project);
299
300 let request = AddDocumentRequest {
301 target: Scope::Project,
302 context: Context::ProjectDev,
303 scope: Scope::Project,
304 path: PathBuf::from("BINARY_DEPENDENCIES.md"),
305 required: true,
306 when: DocumentWhen::Always,
307 notes: Some("External runtime tools required by this project".to_string()),
308 };
309
310 let report = upsert_document(&roots, request).expect("upsert should succeed");
311 assert_eq!(report.target, Scope::Project);
312 assert!(report.created_config);
313 assert_eq!(report.action, AddDocumentAction::Inserted);
314 assert_eq!(report.document_count, 1);
315 assert_eq!(report.config_path, project.path().join(CONFIG_FILE_NAME));
316
317 let written =
318 fs::read_to_string(project.path().join(CONFIG_FILE_NAME)).expect("read written file");
319 assert!(written.contains("[[document]]"));
320 assert!(written.contains("context = \"project-dev\""));
321 assert!(written.contains("scope = \"project\""));
322 assert!(written.contains("path = \"BINARY_DEPENDENCIES.md\""));
323 assert!(written.contains("required = true"));
324 assert!(written.contains("when = \"always\""));
325
326 let loaded = load_scope_config(Scope::Project, project.path())
327 .expect("load config")
328 .expect("config should exist");
329 assert_eq!(loaded.documents, vec![report.entry]);
330 }
331
332 #[test]
333 fn upsert_document_updates_existing_key_without_duplicate_entries() {
334 let home = TempDir::new().expect("create home tempdir");
335 let project = TempDir::new().expect("create project tempdir");
336 let roots = roots(&home, &project);
337
338 let initial = AddDocumentRequest {
339 target: Scope::Home,
340 context: Context::TaskTools,
341 scope: Scope::Home,
342 path: PathBuf::from("CLI_TOOLS.md"),
343 required: false,
344 when: DocumentWhen::Always,
345 notes: Some("initial".to_string()),
346 };
347 upsert_document(&roots, initial).expect("initial insert");
348
349 let update = AddDocumentRequest {
350 target: Scope::Home,
351 context: Context::TaskTools,
352 scope: Scope::Home,
353 path: PathBuf::from("CLI_TOOLS.md"),
354 required: true,
355 when: DocumentWhen::Always,
356 notes: Some("updated".to_string()),
357 };
358 let report = upsert_document(&roots, update.clone()).expect("update should succeed");
359 assert!(!report.created_config);
360 assert_eq!(report.action, AddDocumentAction::Updated);
361 assert_eq!(report.document_count, 1);
362
363 let after_update =
364 fs::read_to_string(home.path().join(CONFIG_FILE_NAME)).expect("read updated file");
365 let second_report = upsert_document(&roots, update).expect("second upsert should succeed");
366 assert_eq!(second_report.action, AddDocumentAction::Updated);
367 let after_second_update =
368 fs::read_to_string(home.path().join(CONFIG_FILE_NAME)).expect("read second update");
369 assert_eq!(after_update, after_second_update);
370
371 let loaded = load_scope_config(Scope::Home, home.path())
372 .expect("load config")
373 .expect("config should exist");
374 assert_eq!(loaded.documents.len(), 1);
375 let only = &loaded.documents[0];
376 assert_eq!(only.context, Context::TaskTools);
377 assert_eq!(only.scope, Scope::Home);
378 assert_eq!(only.path, Path::new("CLI_TOOLS.md"));
379 assert!(only.required);
380 assert_eq!(only.when, DocumentWhen::Always);
381 assert_eq!(only.notes.as_deref(), Some("updated"));
382 }
383
384 #[test]
385 fn upsert_document_deduplicates_existing_same_key_entries() {
386 let home = TempDir::new().expect("create home tempdir");
387 let project = TempDir::new().expect("create project tempdir");
388 fs::write(
389 project.path().join(CONFIG_FILE_NAME),
390 r#"
391[[document]]
392context = "project-dev"
393scope = "project"
394path = "BINARY_DEPENDENCIES.md"
395required = false
396when = "always"
397notes = "first"
398
399[[document]]
400context = "task-tools"
401scope = "home"
402path = "CLI_TOOLS.md"
403required = true
404when = "always"
405notes = "other"
406
407[[document]]
408context = "project-dev"
409scope = "project"
410path = "BINARY_DEPENDENCIES.md"
411required = true
412when = "always"
413notes = "second"
414"#,
415 )
416 .expect("seed duplicate config");
417
418 let roots = roots(&home, &project);
419 let report = upsert_document(
420 &roots,
421 AddDocumentRequest {
422 target: Scope::Project,
423 context: Context::ProjectDev,
424 scope: Scope::Project,
425 path: PathBuf::from("BINARY_DEPENDENCIES.md"),
426 required: true,
427 when: DocumentWhen::Always,
428 notes: Some("deduped".to_string()),
429 },
430 )
431 .expect("upsert should succeed");
432 assert_eq!(report.action, AddDocumentAction::Updated);
433 assert_eq!(report.document_count, 2);
434
435 let loaded = load_scope_config(Scope::Project, project.path())
436 .expect("load config")
437 .expect("config should exist");
438 let duplicates: Vec<_> = loaded
439 .documents
440 .iter()
441 .filter(|document| {
442 document.context == Context::ProjectDev
443 && document.scope == Scope::Project
444 && document.path == Path::new("BINARY_DEPENDENCIES.md")
445 })
446 .collect();
447 assert_eq!(duplicates.len(), 1);
448 assert_eq!(duplicates[0].notes.as_deref(), Some("deduped"));
449 }
450
451 #[test]
452 fn upsert_document_rejects_empty_path_after_trim() {
453 let home = TempDir::new().expect("create home tempdir");
454 let project = TempDir::new().expect("create project tempdir");
455 let roots = roots(&home, &project);
456
457 let err = upsert_document(
458 &roots,
459 AddDocumentRequest {
460 target: Scope::Project,
461 context: Context::ProjectDev,
462 scope: Scope::Project,
463 path: PathBuf::from(" "),
464 required: true,
465 when: DocumentWhen::Always,
466 notes: None,
467 },
468 )
469 .expect_err("empty path should be rejected");
470 assert_eq!(err.kind, ConfigErrorKind::Validation);
471 assert_eq!(err.field.as_deref(), Some("path"));
472 assert!(err.message.contains("path cannot be empty"));
473 }
474
475 #[test]
476 fn upsert_document_preserves_entries_after_updated_key() {
477 let home = TempDir::new().expect("create home tempdir");
478 let project = TempDir::new().expect("create project tempdir");
479 fs::write(
480 home.path().join(CONFIG_FILE_NAME),
481 r#"
482[[document]]
483context = "task-tools"
484scope = "home"
485path = "CLI_TOOLS.md"
486required = false
487when = "always"
488notes = "before"
489
490[[document]]
491context = "skill-dev"
492scope = "home"
493path = "DEVELOPMENT.md"
494required = true
495when = "always"
496notes = "tail"
497"#,
498 )
499 .expect("seed config");
500
501 let roots = roots(&home, &project);
502 let report = upsert_document(
503 &roots,
504 AddDocumentRequest {
505 target: Scope::Home,
506 context: Context::TaskTools,
507 scope: Scope::Home,
508 path: PathBuf::from("CLI_TOOLS.md"),
509 required: true,
510 when: DocumentWhen::Always,
511 notes: Some("after".to_string()),
512 },
513 )
514 .expect("upsert should succeed");
515 assert_eq!(report.action, AddDocumentAction::Updated);
516 assert_eq!(report.document_count, 2);
517
518 let loaded = load_scope_config(Scope::Home, home.path())
519 .expect("load config")
520 .expect("config should exist");
521 assert_eq!(loaded.documents.len(), 2);
522 assert_eq!(loaded.documents[0].context, Context::TaskTools);
523 assert_eq!(loaded.documents[0].notes.as_deref(), Some("after"));
524 assert_eq!(loaded.documents[1].context, Context::SkillDev);
525 assert_eq!(loaded.documents[1].notes.as_deref(), Some("tail"));
526 }
527
528 #[test]
529 fn upsert_document_preserves_top_level_comments() {
530 let home = TempDir::new().expect("create home tempdir");
531 let project = TempDir::new().expect("create project tempdir");
532 fs::write(
533 home.path().join(CONFIG_FILE_NAME),
534 r#"# keep this comment
535
536[[document]]
537context = "task-tools"
538scope = "home"
539path = "CLI_TOOLS.md"
540required = false
541when = "always"
542notes = "before"
543"#,
544 )
545 .expect("seed commented config");
546
547 let roots = roots(&home, &project);
548 upsert_document(
549 &roots,
550 AddDocumentRequest {
551 target: Scope::Home,
552 context: Context::TaskTools,
553 scope: Scope::Home,
554 path: PathBuf::from("CLI_TOOLS.md"),
555 required: true,
556 when: DocumentWhen::Always,
557 notes: Some("after".to_string()),
558 },
559 )
560 .expect("upsert should succeed");
561
562 let written = fs::read_to_string(home.path().join(CONFIG_FILE_NAME)).expect("read config");
563 assert!(written.contains("# keep this comment"));
564 assert!(written.contains("notes = \"after\""));
565 }
566
567 #[test]
568 fn upsert_document_preserves_inline_comments_on_updated_table() {
569 let home = TempDir::new().expect("create home tempdir");
570 let project = TempDir::new().expect("create project tempdir");
571 fs::write(
572 home.path().join(CONFIG_FILE_NAME),
573 r#"# keep file header
574
575[[document]]
576context = "task-tools" # keep context comment
577scope = "home" # keep scope comment
578path = "CLI_TOOLS.md" # keep path comment
579required = false # keep required comment
580when = "always" # keep when comment
581notes = "before" # keep notes comment
582"#,
583 )
584 .expect("seed commented config");
585
586 let roots = roots(&home, &project);
587 upsert_document(
588 &roots,
589 AddDocumentRequest {
590 target: Scope::Home,
591 context: Context::TaskTools,
592 scope: Scope::Home,
593 path: PathBuf::from("CLI_TOOLS.md"),
594 required: true,
595 when: DocumentWhen::Always,
596 notes: Some("after".to_string()),
597 },
598 )
599 .expect("upsert should succeed");
600
601 let written = fs::read_to_string(home.path().join(CONFIG_FILE_NAME)).expect("read config");
602 assert!(written.contains("# keep file header"));
603 assert!(written.contains("# keep context comment"));
604 assert!(written.contains("# keep scope comment"));
605 assert!(written.contains("# keep path comment"));
606 assert!(written.contains("# keep required comment"));
607 assert!(written.contains("# keep when comment"));
608 assert!(written.contains("# keep notes comment"));
609 assert!(written.contains("required = true"));
610 assert!(written.contains("notes = \"after\""));
611 }
612}