Skip to main content

agent_docs/commands/
add.rs

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    // Keep existing behavior: malformed existing config fails with a validation/parse error.
60    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}