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 }
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 assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
394
395 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 assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
423
424 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 assert_eq!(keys.len(), 1);
458 }
459}
460
461#[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, #[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}