Skip to main content

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                assert_eq!(rm.ref_name, None);
366            }
367            _ => panic!("Expected WithMetadata"),
368        }
369    }
370
371    #[test]
372    fn test_reference_entry_deserialize_with_metadata_no_description() {
373        let json = r#"{"remote": "https://github.com/org/repo.git"}"#;
374        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
375        match entry {
376            ReferenceEntry::WithMetadata(rm) => {
377                assert_eq!(rm.remote, "https://github.com/org/repo.git");
378                assert_eq!(rm.description, None);
379                assert_eq!(rm.ref_name, None);
380            }
381            _ => panic!("Expected WithMetadata"),
382        }
383    }
384
385    #[test]
386    fn test_reference_entry_deserialize_with_ref_metadata() {
387        let json = r#"{"remote": "https://github.com/org/repo.git", "ref": "refs/tags/v1.2.3"}"#;
388        let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
389        match entry {
390            ReferenceEntry::WithMetadata(rm) => {
391                assert_eq!(rm.remote, "https://github.com/org/repo.git");
392                assert_eq!(rm.ref_name.as_deref(), Some("refs/tags/v1.2.3"));
393            }
394            _ => panic!("Expected WithMetadata"),
395        }
396    }
397
398    #[test]
399    fn test_reference_entry_mixed_array() {
400        let json = r#"[
401            "git@github.com:org/ref1.git",
402            {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
403        ]"#;
404        let entries: Vec<ReferenceEntry> = serde_json::from_str(json).unwrap();
405        assert_eq!(entries.len(), 2);
406
407        // First should be Simple
408        assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
409
410        // Second should be WithMetadata
411        match &entries[1] {
412            ReferenceEntry::WithMetadata(rm) => {
413                assert_eq!(rm.remote, "https://github.com/org/ref2.git");
414                assert_eq!(rm.description.as_deref(), Some("Ref 2"));
415                assert_eq!(rm.ref_name, None);
416            }
417            _ => panic!("Expected WithMetadata"),
418        }
419    }
420
421    #[test]
422    fn test_repo_config_v2_with_reference_entries() {
423        let json = r#"{
424            "version": "2.0",
425            "mount_dirs": {},
426            "context_mounts": [],
427            "references": [
428                "git@github.com:org/ref1.git",
429                {"remote": "https://github.com/org/ref2.git", "description": "Reference 2"}
430            ]
431        }"#;
432
433        let config: RepoConfigV2 = serde_json::from_str(json).unwrap();
434        assert_eq!(config.version, "2.0");
435        assert_eq!(config.references.len(), 2);
436
437        // Verify first reference (simple)
438        assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
439
440        // Verify second reference (with metadata)
441        match &config.references[1] {
442            ReferenceEntry::WithMetadata(rm) => {
443                assert_eq!(rm.description.as_deref(), Some("Reference 2"));
444                assert_eq!(rm.ref_name, None);
445            }
446            _ => panic!("Expected WithMetadata"),
447        }
448    }
449
450    #[test]
451    fn test_cross_variant_duplicate_detection() {
452        use crate::config::validation::canonical_reference_key;
453        use std::collections::HashSet;
454
455        let entries = vec![
456            ReferenceEntry::Simple("git@github.com:User/Repo.git".to_string()),
457            ReferenceEntry::WithMetadata(ReferenceMount {
458                remote: "https://github.com/user/repo".to_string(),
459                description: Some("Same repo".into()),
460                ref_name: None,
461            }),
462        ];
463
464        let mut keys = HashSet::new();
465        for e in &entries {
466            let url = match e {
467                ReferenceEntry::Simple(s) => s.as_str(),
468                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
469            };
470            let key = canonical_reference_key(url).unwrap();
471            keys.insert(key);
472        }
473
474        // Both variants should normalize to the same canonical key
475        assert_eq!(keys.len(), 1);
476    }
477}
478
479// New v2 config types
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct MountDirsV2 {
482    #[serde(default = "default_thoughts_dir")]
483    pub thoughts: String,
484    #[serde(default = "default_context_dir")]
485    pub context: String,
486    #[serde(default = "default_references_dir")]
487    pub references: String,
488}
489
490impl Default for MountDirsV2 {
491    fn default() -> Self {
492        Self {
493            thoughts: default_thoughts_dir(),
494            context: default_context_dir(),
495            references: default_references_dir(),
496        }
497    }
498}
499
500fn default_thoughts_dir() -> String {
501    "thoughts".into()
502}
503fn default_context_dir() -> String {
504    "context".into()
505}
506fn default_references_dir() -> String {
507    "references".into()
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct ThoughtsMount {
512    pub remote: String,
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub subpath: Option<String>,
515    #[serde(default = "default_git_sync")]
516    pub sync: SyncStrategy,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct ContextMount {
521    pub remote: String,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub subpath: Option<String>,
524    pub mount_path: String,
525    #[serde(default = "default_git_sync")]
526    pub sync: SyncStrategy,
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct ReferenceMount {
531    pub remote: String,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub description: Option<String>,
534    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
535    pub ref_name: Option<String>,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
539#[serde(untagged)]
540pub enum ReferenceEntry {
541    Simple(String),
542    WithMetadata(ReferenceMount),
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct RepoConfigV2 {
547    pub version: String, // "2.0"
548    #[serde(default)]
549    pub mount_dirs: MountDirsV2,
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub thoughts_mount: Option<ThoughtsMount>,
552    #[serde(default)]
553    pub context_mounts: Vec<ContextMount>,
554    #[serde(default)]
555    pub references: Vec<ReferenceEntry>,
556}