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(), 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, #[serde(default = "default_git_sync")]
32 sync: SyncStrategy,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 subpath: Option<String>, },
36}
37
38fn default_git_sync() -> SyncStrategy {
39 SyncStrategy::Auto
40}
41
42impl Mount {
44 #[cfg(test)] 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#[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 #[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 assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
180
181 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 assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
210
211 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 assert_eq!(keys.len(), 1);
247 }
248}
249
250#[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, #[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}