thoughts_tool/config/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Config {
7    pub version: String,
8    pub mounts: HashMap<String, Mount>,
9}
10
11impl Default for Config {
12    fn default() -> Self {
13        Self {
14            version: "2.0".to_string(), // New version for URL-based configs
15            mounts: HashMap::new(),
16        }
17    }
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "lowercase")]
22pub enum Mount {
23    Directory {
24        path: PathBuf,
25        #[serde(default)]
26        sync: SyncStrategy,
27    },
28    Git {
29        url: String, // ONLY the URL - no paths!
30        #[serde(default = "default_git_sync")]
31        sync: SyncStrategy,
32        #[serde(skip_serializing_if = "Option::is_none")]
33        subpath: Option<String>, // For mounts like "url:docs/api"
34    },
35}
36
37fn default_git_sync() -> SyncStrategy {
38    SyncStrategy::Auto
39}
40
41// Helper methods for compatibility with existing code
42impl Mount {
43    #[cfg(test)] // Only used in tests
44    pub fn is_git(&self) -> bool {
45        matches!(self, Mount::Git { .. })
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51#[derive(Default)]
52pub enum SyncStrategy {
53    #[default]
54    None,
55    Auto,
56}
57
58impl std::str::FromStr for SyncStrategy {
59    type Err = anyhow::Error;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        match s.to_lowercase().as_str() {
63            "none" => Ok(SyncStrategy::None),
64            "auto" => Ok(SyncStrategy::Auto),
65            _ => Err(anyhow::anyhow!(
66                "Invalid sync strategy: {}. Must be 'none' or 'auto'",
67                s
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            SyncStrategy::None => write!(f, "none"),
77            SyncStrategy::Auto => write!(f, "auto"),
78        }
79    }
80}
81
82// Add after line 101 (after existing types)
83// use std::collections::HashMap; - already imported at the top
84
85// New configuration structures
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct RepoConfig {
88    pub version: String,
89    #[serde(default)]
90    pub mount_dirs: MountDirs,
91    #[serde(default)]
92    pub requires: Vec<RequiredMount>,
93    #[serde(default)]
94    pub rules: Vec<Rule>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MountDirs {
99    #[serde(default = "default_repository_dir")]
100    pub repository: String,
101    #[serde(default = "default_personal_dir")]
102    pub personal: String,
103}
104
105impl Default for MountDirs {
106    fn default() -> Self {
107        Self {
108            repository: default_repository_dir(),
109            personal: default_personal_dir(),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct RequiredMount {
116    pub remote: String,
117    pub mount_path: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub subpath: Option<String>,
120    pub description: String,
121    #[serde(default)]
122    pub optional: bool,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub override_rules: Option<bool>,
125    #[serde(default = "default_sync_strategy")]
126    pub sync: SyncStrategy,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PersonalConfig {
131    #[serde(default)]
132    pub patterns: Vec<MountPattern>,
133    #[serde(default)]
134    pub repository_mounts: HashMap<String, Vec<PersonalMount>>,
135    #[serde(default)]
136    pub rules: Vec<Rule>,
137    #[serde(default)]
138    pub default_mount_dirs: MountDirs,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct MountPattern {
143    pub match_remote: String,
144    pub personal_mounts: Vec<PersonalMount>,
145    pub description: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct PersonalMount {
150    pub remote: String,
151    pub mount_path: String,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub subpath: Option<String>,
154    pub description: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Rule {
159    pub pattern: String,
160    pub metadata: HashMap<String, serde_json::Value>,
161    pub description: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct FileMetadata {
166    #[serde(flatten)]
167    pub auto_metadata: HashMap<String, serde_json::Value>,
168    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
169    pub manual_metadata: HashMap<String, serde_json::Value>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub last_updated: Option<chrono::DateTime<chrono::Utc>>,
172}
173
174fn default_repository_dir() -> String {
175    "context".to_string()
176}
177
178fn default_personal_dir() -> String {
179    "personal".to_string()
180}
181
182fn default_sync_strategy() -> SyncStrategy {
183    SyncStrategy::None
184}
185
186/// Maps git URLs to local filesystem paths
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct RepoMapping {
189    pub version: String,
190    pub mappings: HashMap<String, RepoLocation>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct RepoLocation {
195    pub path: PathBuf,
196    pub auto_managed: bool,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
199}
200
201impl Default for RepoMapping {
202    fn default() -> Self {
203        Self {
204            version: "1.0".to_string(),
205            mappings: HashMap::new(),
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_repo_config_serialization() {
216        let config = RepoConfig {
217            version: "1.0".to_string(),
218            mount_dirs: MountDirs::default(),
219            requires: vec![RequiredMount {
220                remote: "git@github.com:example/repo.git".to_string(),
221                mount_path: "repo".to_string(),
222                subpath: None,
223                description: "Example repository".to_string(),
224                optional: false,
225                override_rules: Some(true),
226                sync: SyncStrategy::Auto,
227            }],
228            rules: vec![Rule {
229                pattern: "*.md".to_string(),
230                metadata: {
231                    let mut m = HashMap::new();
232                    m.insert(
233                        "type".to_string(),
234                        serde_json::Value::String("documentation".to_string()),
235                    );
236                    m
237                },
238                description: "Markdown files".to_string(),
239            }],
240        };
241
242        // Serialize
243        let json = serde_json::to_string_pretty(&config).unwrap();
244
245        // Deserialize
246        let deserialized: RepoConfig = serde_json::from_str(&json).unwrap();
247
248        // Verify
249        assert_eq!(deserialized.version, config.version);
250        assert_eq!(
251            deserialized.mount_dirs.repository,
252            config.mount_dirs.repository
253        );
254        assert_eq!(deserialized.mount_dirs.personal, config.mount_dirs.personal);
255        assert_eq!(deserialized.requires.len(), config.requires.len());
256        assert_eq!(deserialized.rules.len(), config.rules.len());
257    }
258
259    #[test]
260    fn test_personal_config_serialization() {
261        let config = PersonalConfig {
262            patterns: vec![MountPattern {
263                match_remote: "git@github.com:mycompany/*".to_string(),
264                personal_mounts: vec![PersonalMount {
265                    remote: "git@github.com:me/notes.git".to_string(),
266                    mount_path: "notes".to_string(),
267                    subpath: None,
268                    description: "My notes".to_string(),
269                }],
270                description: "Company projects".to_string(),
271            }],
272            repository_mounts: {
273                let mut m = HashMap::new();
274                m.insert(
275                    "git@github.com:example/project.git".to_string(),
276                    vec![PersonalMount {
277                        remote: "git@github.com:me/personal.git".to_string(),
278                        mount_path: "personal".to_string(),
279                        subpath: None,
280                        description: "Personal files".to_string(),
281                    }],
282                );
283                m
284            },
285            rules: vec![],
286            default_mount_dirs: MountDirs::default(),
287        };
288
289        // Serialize
290        let json = serde_json::to_string_pretty(&config).unwrap();
291
292        // Deserialize
293        let deserialized: PersonalConfig = serde_json::from_str(&json).unwrap();
294
295        // Verify
296        assert_eq!(deserialized.patterns.len(), config.patterns.len());
297        assert_eq!(
298            deserialized.repository_mounts.len(),
299            config.repository_mounts.len()
300        );
301        assert_eq!(deserialized.rules.len(), config.rules.len());
302    }
303
304    #[test]
305    fn test_mount_dirs_defaults() {
306        let dirs = MountDirs::default();
307        assert_eq!(dirs.repository, "context");
308        assert_eq!(dirs.personal, "personal");
309    }
310
311    #[test]
312    fn test_file_metadata_serialization() {
313        let metadata = FileMetadata {
314            auto_metadata: {
315                let mut m = HashMap::new();
316                m.insert(
317                    "type".to_string(),
318                    serde_json::Value::String("research".to_string()),
319                );
320                m.insert(
321                    "tags".to_string(),
322                    serde_json::Value::Array(vec![
323                        serde_json::Value::String("important".to_string()),
324                        serde_json::Value::String("review".to_string()),
325                    ]),
326                );
327                m
328            },
329            manual_metadata: HashMap::new(),
330            last_updated: Some(chrono::Utc::now()),
331        };
332
333        // Serialize
334        let json = serde_json::to_string_pretty(&metadata).unwrap();
335
336        // Deserialize
337        let deserialized: FileMetadata = serde_json::from_str(&json).unwrap();
338
339        // Verify
340        assert_eq!(
341            deserialized.auto_metadata.len(),
342            metadata.auto_metadata.len()
343        );
344        assert!(deserialized.last_updated.is_some());
345    }
346
347    #[test]
348    fn test_reference_entry_deserialize_simple() {
349        let json = r#""git@github.com:org/repo.git""#;
350        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
351        assert!(matches!(entry, ReferenceEntry::Simple(_)));
352        if let ReferenceEntry::Simple(url) = entry {
353            assert_eq!(url, "git@github.com:org/repo.git");
354        }
355    }
356
357    #[test]
358    fn test_reference_entry_deserialize_with_metadata() {
359        let json = r#"{"remote": "https://github.com/org/repo.git", "description": "Test repo"}"#;
360        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
361        match entry {
362            ReferenceEntry::WithMetadata(rm) => {
363                assert_eq!(rm.remote, "https://github.com/org/repo.git");
364                assert_eq!(rm.description.as_deref(), Some("Test repo"));
365            }
366            _ => panic!("Expected WithMetadata"),
367        }
368    }
369
370    #[test]
371    fn test_reference_entry_deserialize_with_metadata_no_description() {
372        let json = r#"{"remote": "https://github.com/org/repo.git"}"#;
373        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
374        match entry {
375            ReferenceEntry::WithMetadata(rm) => {
376                assert_eq!(rm.remote, "https://github.com/org/repo.git");
377                assert_eq!(rm.description, None);
378            }
379            _ => panic!("Expected WithMetadata"),
380        }
381    }
382
383    #[test]
384    fn test_reference_entry_mixed_array() {
385        let json = r#"[
386            "git@github.com:org/ref1.git",
387            {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
388        ]"#;
389        let entries: Vec<ReferenceEntry> = serde_json::from_str(json).unwrap();
390        assert_eq!(entries.len(), 2);
391
392        // First should be Simple
393        assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
394
395        // Second should be WithMetadata
396        match &entries[1] {
397            ReferenceEntry::WithMetadata(rm) => {
398                assert_eq!(rm.remote, "https://github.com/org/ref2.git");
399                assert_eq!(rm.description.as_deref(), Some("Ref 2"));
400            }
401            _ => panic!("Expected WithMetadata"),
402        }
403    }
404
405    #[test]
406    fn test_repo_config_v2_with_reference_entries() {
407        let json = r#"{
408            "version": "2.0",
409            "mount_dirs": {},
410            "context_mounts": [],
411            "references": [
412                "git@github.com:org/ref1.git",
413                {"remote": "https://github.com/org/ref2.git", "description": "Reference 2"}
414            ]
415        }"#;
416
417        let config: RepoConfigV2 = serde_json::from_str(json).unwrap();
418        assert_eq!(config.version, "2.0");
419        assert_eq!(config.references.len(), 2);
420
421        // Verify first reference (simple)
422        assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
423
424        // Verify second reference (with metadata)
425        match &config.references[1] {
426            ReferenceEntry::WithMetadata(rm) => {
427                assert_eq!(rm.description.as_deref(), Some("Reference 2"));
428            }
429            _ => panic!("Expected WithMetadata"),
430        }
431    }
432
433    #[test]
434    fn test_cross_variant_duplicate_detection() {
435        use crate::config::validation::canonical_reference_key;
436        use std::collections::HashSet;
437
438        let entries = vec![
439            ReferenceEntry::Simple("git@github.com:User/Repo.git".to_string()),
440            ReferenceEntry::WithMetadata(ReferenceMount {
441                remote: "https://github.com/user/repo".to_string(),
442                description: Some("Same repo".into()),
443            }),
444        ];
445
446        let mut keys = HashSet::new();
447        for e in &entries {
448            let url = match e {
449                ReferenceEntry::Simple(s) => s.as_str(),
450                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
451            };
452            let key = canonical_reference_key(url).unwrap();
453            keys.insert(key);
454        }
455
456        // Both variants should normalize to the same canonical key
457        assert_eq!(keys.len(), 1);
458    }
459}
460
461// New v2 config types
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct MountDirsV2 {
464    #[serde(default = "default_thoughts_dir")]
465    pub thoughts: String,
466    #[serde(default = "default_context_dir")]
467    pub context: String,
468    #[serde(default = "default_references_dir")]
469    pub references: String,
470}
471
472impl Default for MountDirsV2 {
473    fn default() -> Self {
474        Self {
475            thoughts: default_thoughts_dir(),
476            context: default_context_dir(),
477            references: default_references_dir(),
478        }
479    }
480}
481
482fn default_thoughts_dir() -> String {
483    "thoughts".into()
484}
485fn default_context_dir() -> String {
486    "context".into()
487}
488fn default_references_dir() -> String {
489    "references".into()
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct ThoughtsMount {
494    pub remote: String,
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub subpath: Option<String>,
497    #[serde(default = "default_git_sync")]
498    pub sync: SyncStrategy,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct ContextMount {
503    pub remote: String,
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub subpath: Option<String>,
506    pub mount_path: String,
507    #[serde(default = "default_git_sync")]
508    pub sync: SyncStrategy,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512pub struct ReferenceMount {
513    pub remote: String,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub description: Option<String>,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
519#[serde(untagged)]
520pub enum ReferenceEntry {
521    Simple(String),
522    WithMetadata(ReferenceMount),
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct RepoConfigV2 {
527    pub version: String, // "2.0"
528    #[serde(default)]
529    pub mount_dirs: MountDirsV2,
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub thoughts_mount: Option<ThoughtsMount>,
532    #[serde(default)]
533    pub context_mounts: Vec<ContextMount>,
534    #[serde(default)]
535    pub references: Vec<ReferenceEntry>,
536}