Skip to main content

whycodes_import/
lib.rs

1//! Import MCP, permission, and hook settings from other coding agents.
2//!
3//! Discovery never reads a file until the user approves that exact path.
4//! Source files are never modified. Symlinks are refused.
5
6pub mod apply;
7pub mod consent;
8pub mod discover;
9pub mod error;
10pub mod extract;
11pub mod parse;
12pub mod types;
13
14pub use apply::{apply, apply_and_save, plan};
15pub use consent::ConsentStore;
16pub use discover::{KNOWN_SOURCES, scan, scan_with_home, why_config_missing};
17pub use error::{ImportError, Result};
18pub use extract::extract;
19pub use types::{
20    Extracted, FoundSource, ImportItem, ImportItemKind, ImportPlan, Product, SourceState,
21};
22
23/// Read approved sources and build a merge plan against `config`.
24pub fn preview(
25    sources: &[FoundSource],
26    config: &whycodes_config::Config,
27    force: bool,
28) -> Result<(Vec<Extracted>, ImportPlan)> {
29    let mut extracted = Vec::new();
30    for src in sources {
31        match src.state {
32            SourceState::Approved => extracted.push(extract(src)?),
33            SourceState::New | SourceState::Denied | SourceState::Symlink => {}
34        }
35    }
36    let plan = plan(config, &extracted, force);
37    Ok((extracted, plan))
38}
39
40/// True when at least one non-symlink foreign settings file exists.
41pub fn has_discoverable_sources(home: &std::path::Path, consent: &ConsentStore) -> bool {
42    scan_with_home(home, consent)
43        .iter()
44        .any(|s| s.state != SourceState::Symlink)
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use std::path::PathBuf;
51
52    #[test]
53    fn preview_skips_unapproved() {
54        let dir = tempfile::tempdir().unwrap();
55        let path = dir.path().join(".claude.json");
56        std::fs::write(&path, r#"{"mcpServers":{"fs":{"command":"npx"}}}"#).unwrap();
57        let src = FoundSource {
58            product: Product::Claude,
59            rel_path: ".claude.json",
60            path: path.clone(),
61            state: SourceState::New,
62        };
63        let cfg = whycodes_config::Config::default();
64        let (ex, plan) = preview(&[src], &cfg, false).unwrap();
65        assert!(ex.is_empty());
66        assert!(plan.is_empty());
67        let src = FoundSource {
68            product: Product::Claude,
69            rel_path: ".claude.json",
70            path,
71            state: SourceState::Approved,
72        };
73        let (ex, plan) = preview(&[src], &cfg, false).unwrap();
74        assert_eq!(ex.len(), 1);
75        assert_eq!(plan.mcp_add.len(), 1);
76    }
77
78    #[test]
79    fn preview_skips_denied_and_symlink_and_surfaces_extract_error() {
80        let dir = tempfile::tempdir().unwrap();
81        let path = dir.path().join(".claude.json");
82        std::fs::write(&path, r#"{"mcpServers":{"fs":{"command":"npx"}}}"#).unwrap();
83        let denied = FoundSource {
84            product: Product::Claude,
85            rel_path: ".claude.json",
86            path: path.clone(),
87            state: SourceState::Denied,
88        };
89        let link = FoundSource {
90            product: Product::Claude,
91            rel_path: ".claude.json",
92            path: path.clone(),
93            state: SourceState::Symlink,
94        };
95        let cfg = whycodes_config::Config::default();
96        let (ex, plan) = preview(&[denied, link], &cfg, false).unwrap();
97        assert!(ex.is_empty());
98        assert!(plan.is_empty());
99        let missing = FoundSource {
100            product: Product::Claude,
101            rel_path: ".claude.json",
102            path: dir.path().join("missing.json"),
103            state: SourceState::Approved,
104        };
105        assert!(preview(&[missing], &cfg, false).is_err());
106    }
107
108    #[test]
109    fn has_discoverable_sources_true() {
110        let dir = tempfile::tempdir().unwrap();
111        std::fs::write(dir.path().join(".claude.json"), "{}").unwrap();
112        let consent = ConsentStore::new(dir.path().join("data"));
113        assert!(has_discoverable_sources(dir.path(), &consent));
114        assert!(!has_discoverable_sources(
115            &PathBuf::from("/no-such-home-xyz"),
116            &consent
117        ));
118    }
119}