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(), 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, #[serde(default = "default_git_sync")]
31 sync: SyncStrategy,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 subpath: Option<String>, },
35}
36
37fn default_git_sync() -> SyncStrategy {
38 SyncStrategy::Auto
39}
40
41impl Mount {
43 #[cfg(test)] 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#[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#[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 let json = serde_json::to_string_pretty(&config).unwrap();
244
245 let deserialized: RepoConfig = serde_json::from_str(&json).unwrap();
247
248 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 let json = serde_json::to_string_pretty(&config).unwrap();
291
292 let deserialized: PersonalConfig = serde_json::from_str(&json).unwrap();
294
295 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 let json = serde_json::to_string_pretty(&metadata).unwrap();
335
336 let deserialized: FileMetadata = serde_json::from_str(&json).unwrap();
338
339 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 assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
409
410 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 assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
439
440 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 assert_eq!(keys.len(), 1);
476 }
477}
478
479#[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, #[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}