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