Skip to main content

thoughts_tool/config/
types.rs

1use serde::Deserialize;
2use serde::Serialize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Config {
8    pub version: String,
9    pub mounts: HashMap<String, Mount>,
10}
11
12impl Default for Config {
13    fn default() -> Self {
14        Self {
15            version: "2.0".to_string(), // New version for URL-based configs
16            mounts: HashMap::new(),
17        }
18    }
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "lowercase")]
23pub enum Mount {
24    Directory {
25        path: PathBuf,
26        #[serde(default)]
27        sync: SyncStrategy,
28    },
29    Git {
30        url: String, // ONLY the URL - no paths!
31        #[serde(default = "default_git_sync")]
32        sync: SyncStrategy,
33        #[serde(skip_serializing_if = "Option::is_none")]
34        subpath: Option<String>, // For mounts like "url:docs/api"
35    },
36}
37
38fn default_git_sync() -> SyncStrategy {
39    SyncStrategy::Auto
40}
41
42// Helper methods for compatibility with existing code
43impl Mount {
44    #[cfg(test)] // Only used in tests
45    pub fn is_git(&self) -> bool {
46        matches!(self, Self::Git { .. })
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52#[derive(Default)]
53pub enum SyncStrategy {
54    #[default]
55    None,
56    Auto,
57}
58
59impl std::str::FromStr for SyncStrategy {
60    type Err = anyhow::Error;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s.to_lowercase().as_str() {
64            "none" => Ok(Self::None),
65            "auto" => Ok(Self::Auto),
66            _ => Err(anyhow::anyhow!(
67                "Invalid sync strategy: {s}. Must be 'none' or 'auto'"
68            )),
69        }
70    }
71}
72
73impl std::fmt::Display for SyncStrategy {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::None => write!(f, "none"),
77            Self::Auto => write!(f, "auto"),
78        }
79    }
80}
81
82// Note: V1 config types (RepoConfig, MountDirs, RequiredMount, PersonalConfig,
83// MountPattern, PersonalMount, Rule, FileMetadata) have been removed.
84// See CLAUDE.md for V2 config API guidance.
85
86/// Maps git URLs to local filesystem paths
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct RepoMapping {
89    pub version: String,
90    pub mappings: HashMap<String, RepoLocation>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct RepoLocation {
95    pub path: PathBuf,
96    pub auto_managed: bool,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
99}
100
101impl Default for RepoMapping {
102    fn default() -> Self {
103        Self {
104            version: "1.0".to_string(),
105            mappings: HashMap::new(),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    // Note: V1 config tests (test_repo_config_serialization, test_personal_config_serialization,
115    // test_mount_dirs_defaults, test_file_metadata_serialization) have been removed.
116
117    #[test]
118    fn test_reference_entry_deserialize_simple() {
119        let json = r#""git@github.com:org/repo.git""#;
120        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
121        assert!(matches!(entry, ReferenceEntry::Simple(_)));
122        if let ReferenceEntry::Simple(url) = entry {
123            assert_eq!(url, "git@github.com:org/repo.git");
124        }
125    }
126
127    #[test]
128    fn test_reference_entry_deserialize_with_metadata() {
129        let json = r#"{"remote": "https://github.com/org/repo.git", "description": "Test repo"}"#;
130        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
131        match entry {
132            ReferenceEntry::WithMetadata(rm) => {
133                assert_eq!(rm.remote, "https://github.com/org/repo.git");
134                assert_eq!(rm.description.as_deref(), Some("Test repo"));
135                assert_eq!(rm.ref_name, None);
136            }
137            ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
138        }
139    }
140
141    #[test]
142    fn test_reference_entry_deserialize_with_metadata_no_description() {
143        let json = r#"{"remote": "https://github.com/org/repo.git"}"#;
144        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
145        match entry {
146            ReferenceEntry::WithMetadata(rm) => {
147                assert_eq!(rm.remote, "https://github.com/org/repo.git");
148                assert_eq!(rm.description, None);
149                assert_eq!(rm.ref_name, None);
150            }
151            ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
152        }
153    }
154
155    #[test]
156    fn test_reference_entry_deserialize_with_ref_metadata() {
157        let json = r#"{"remote": "https://github.com/org/repo.git", "ref": "refs/tags/v1.2.3"}"#;
158        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
159        match entry {
160            ReferenceEntry::WithMetadata(rm) => {
161                assert_eq!(rm.remote, "https://github.com/org/repo.git");
162                assert_eq!(rm.ref_name.as_deref(), Some("refs/tags/v1.2.3"));
163            }
164            ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
165        }
166    }
167
168    #[test]
169    fn test_reference_entry_mixed_array() {
170        let json = r#"[
171            "git@github.com:org/ref1.git",
172            {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
173        ]"#;
174        let entries: Vec<ReferenceEntry> = serde_json::from_str(json).unwrap();
175        assert_eq!(entries.len(), 2);
176
177        // First should be Simple
178        assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
179
180        // Second should be WithMetadata
181        match &entries[1] {
182            ReferenceEntry::WithMetadata(rm) => {
183                assert_eq!(rm.remote, "https://github.com/org/ref2.git");
184                assert_eq!(rm.description.as_deref(), Some("Ref 2"));
185                assert_eq!(rm.ref_name, None);
186            }
187            ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
188        }
189    }
190
191    #[test]
192    fn test_repo_config_v2_with_reference_entries() {
193        let json = r#"{
194            "version": "2.0",
195            "mount_dirs": {},
196            "context_mounts": [],
197            "references": [
198                "git@github.com:org/ref1.git",
199                {"remote": "https://github.com/org/ref2.git", "description": "Reference 2"}
200            ]
201        }"#;
202
203        let config: RepoConfigV2 = serde_json::from_str(json).unwrap();
204        assert_eq!(config.version, "2.0");
205        assert_eq!(config.references.len(), 2);
206
207        // Verify first reference (simple)
208        assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
209
210        // Verify second reference (with metadata)
211        match &config.references[1] {
212            ReferenceEntry::WithMetadata(rm) => {
213                assert_eq!(rm.description.as_deref(), Some("Reference 2"));
214                assert_eq!(rm.ref_name, None);
215            }
216            ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
217        }
218    }
219
220    #[test]
221    fn test_cross_variant_duplicate_detection() {
222        use crate::config::validation::canonical_reference_key;
223        use std::collections::HashSet;
224
225        let entries = vec![
226            ReferenceEntry::Simple("git@github.com:User/Repo.git".to_string()),
227            ReferenceEntry::WithMetadata(ReferenceMount {
228                remote: "https://github.com/user/repo".to_string(),
229                description: Some("Same repo".into()),
230                ref_name: None,
231            }),
232        ];
233
234        let mut keys = HashSet::new();
235        for e in &entries {
236            let url = match e {
237                ReferenceEntry::Simple(s) => s.as_str(),
238                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
239            };
240            let key = canonical_reference_key(url).unwrap();
241            keys.insert(key);
242        }
243
244        // Both variants should normalize to the same canonical key
245        assert_eq!(keys.len(), 1);
246    }
247}
248
249// New v2 config types
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct MountDirsV2 {
252    #[serde(default = "default_thoughts_dir")]
253    pub thoughts: String,
254    #[serde(default = "default_context_dir")]
255    pub context: String,
256    #[serde(default = "default_references_dir")]
257    pub references: String,
258}
259
260impl Default for MountDirsV2 {
261    fn default() -> Self {
262        Self {
263            thoughts: default_thoughts_dir(),
264            context: default_context_dir(),
265            references: default_references_dir(),
266        }
267    }
268}
269
270fn default_thoughts_dir() -> String {
271    "thoughts".into()
272}
273fn default_context_dir() -> String {
274    "context".into()
275}
276fn default_references_dir() -> String {
277    "references".into()
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ThoughtsMount {
282    pub remote: String,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub subpath: Option<String>,
285    #[serde(default = "default_git_sync")]
286    pub sync: SyncStrategy,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ContextMount {
291    pub remote: String,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub subpath: Option<String>,
294    pub mount_path: String,
295    #[serde(default = "default_git_sync")]
296    pub sync: SyncStrategy,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ReferenceMount {
301    pub remote: String,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub description: Option<String>,
304    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
305    pub ref_name: Option<String>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(untagged)]
310pub enum ReferenceEntry {
311    Simple(String),
312    WithMetadata(ReferenceMount),
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct RepoConfigV2 {
317    pub version: String, // "2.0"
318    #[serde(default)]
319    pub mount_dirs: MountDirsV2,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub thoughts_mount: Option<ThoughtsMount>,
322    #[serde(default)]
323    pub context_mounts: Vec<ContextMount>,
324    #[serde(default)]
325    pub references: Vec<ReferenceEntry>,
326}