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, Self::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(Self::None),
65 "auto" => Ok(Self::Auto),
66 _ => Err(anyhow::anyhow!(
67 "Invalid sync strategy: {s}. Must be 'none' or 'auto'"
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 Self::None => write!(f, "none"),
77 Self::Auto => write!(f, "auto"),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct RepoMapping {
89 pub version: String,
90 pub mappings: HashMap<String, RepoLocation>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct RepoLocation {
95 pub path: PathBuf,
96 pub auto_managed: bool,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
99}
100
101impl Default for RepoMapping {
102 fn default() -> Self {
103 Self {
104 version: "1.0".to_string(),
105 mappings: HashMap::new(),
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
118 fn test_reference_entry_deserialize_simple() {
119 let json = r#""git@github.com:org/repo.git""#;
120 let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
121 assert!(matches!(entry, ReferenceEntry::Simple(_)));
122 if let ReferenceEntry::Simple(url) = entry {
123 assert_eq!(url, "git@github.com:org/repo.git");
124 }
125 }
126
127 #[test]
128 fn test_reference_entry_deserialize_with_metadata() {
129 let json = r#"{"remote": "https://github.com/org/repo.git", "description": "Test repo"}"#;
130 let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
131 match entry {
132 ReferenceEntry::WithMetadata(rm) => {
133 assert_eq!(rm.remote, "https://github.com/org/repo.git");
134 assert_eq!(rm.description.as_deref(), Some("Test repo"));
135 assert_eq!(rm.ref_name, None);
136 }
137 ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
138 }
139 }
140
141 #[test]
142 fn test_reference_entry_deserialize_with_metadata_no_description() {
143 let json = r#"{"remote": "https://github.com/org/repo.git"}"#;
144 let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
145 match entry {
146 ReferenceEntry::WithMetadata(rm) => {
147 assert_eq!(rm.remote, "https://github.com/org/repo.git");
148 assert_eq!(rm.description, None);
149 assert_eq!(rm.ref_name, None);
150 }
151 ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
152 }
153 }
154
155 #[test]
156 fn test_reference_entry_deserialize_with_ref_metadata() {
157 let json = r#"{"remote": "https://github.com/org/repo.git", "ref": "refs/tags/v1.2.3"}"#;
158 let entry: ReferenceEntry = serde_json::from_str(json).unwrap();
159 match entry {
160 ReferenceEntry::WithMetadata(rm) => {
161 assert_eq!(rm.remote, "https://github.com/org/repo.git");
162 assert_eq!(rm.ref_name.as_deref(), Some("refs/tags/v1.2.3"));
163 }
164 ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
165 }
166 }
167
168 #[test]
169 fn test_reference_entry_mixed_array() {
170 let json = r#"[
171 "git@github.com:org/ref1.git",
172 {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
173 ]"#;
174 let entries: Vec<ReferenceEntry> = serde_json::from_str(json).unwrap();
175 assert_eq!(entries.len(), 2);
176
177 assert!(matches!(entries[0], ReferenceEntry::Simple(_)));
179
180 match &entries[1] {
182 ReferenceEntry::WithMetadata(rm) => {
183 assert_eq!(rm.remote, "https://github.com/org/ref2.git");
184 assert_eq!(rm.description.as_deref(), Some("Ref 2"));
185 assert_eq!(rm.ref_name, None);
186 }
187 ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
188 }
189 }
190
191 #[test]
192 fn test_repo_config_v2_with_reference_entries() {
193 let json = r#"{
194 "version": "2.0",
195 "mount_dirs": {},
196 "context_mounts": [],
197 "references": [
198 "git@github.com:org/ref1.git",
199 {"remote": "https://github.com/org/ref2.git", "description": "Reference 2"}
200 ]
201 }"#;
202
203 let config: RepoConfigV2 = serde_json::from_str(json).unwrap();
204 assert_eq!(config.version, "2.0");
205 assert_eq!(config.references.len(), 2);
206
207 assert!(matches!(config.references[0], ReferenceEntry::Simple(_)));
209
210 match &config.references[1] {
212 ReferenceEntry::WithMetadata(rm) => {
213 assert_eq!(rm.description.as_deref(), Some("Reference 2"));
214 assert_eq!(rm.ref_name, None);
215 }
216 ReferenceEntry::Simple(_) => panic!("Expected WithMetadata"),
217 }
218 }
219
220 #[test]
221 fn test_cross_variant_duplicate_detection() {
222 use crate::config::validation::canonical_reference_key;
223 use std::collections::HashSet;
224
225 let entries = vec![
226 ReferenceEntry::Simple("git@github.com:User/Repo.git".to_string()),
227 ReferenceEntry::WithMetadata(ReferenceMount {
228 remote: "https://github.com/user/repo".to_string(),
229 description: Some("Same repo".into()),
230 ref_name: None,
231 }),
232 ];
233
234 let mut keys = HashSet::new();
235 for e in &entries {
236 let url = match e {
237 ReferenceEntry::Simple(s) => s.as_str(),
238 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
239 };
240 let key = canonical_reference_key(url).unwrap();
241 keys.insert(key);
242 }
243
244 assert_eq!(keys.len(), 1);
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct MountDirsV2 {
252 #[serde(default = "default_thoughts_dir")]
253 pub thoughts: String,
254 #[serde(default = "default_context_dir")]
255 pub context: String,
256 #[serde(default = "default_references_dir")]
257 pub references: String,
258}
259
260impl Default for MountDirsV2 {
261 fn default() -> Self {
262 Self {
263 thoughts: default_thoughts_dir(),
264 context: default_context_dir(),
265 references: default_references_dir(),
266 }
267 }
268}
269
270fn default_thoughts_dir() -> String {
271 "thoughts".into()
272}
273fn default_context_dir() -> String {
274 "context".into()
275}
276fn default_references_dir() -> String {
277 "references".into()
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ThoughtsMount {
282 pub remote: String,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub subpath: Option<String>,
285 #[serde(default = "default_git_sync")]
286 pub sync: SyncStrategy,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ContextMount {
291 pub remote: String,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub subpath: Option<String>,
294 pub mount_path: String,
295 #[serde(default = "default_git_sync")]
296 pub sync: SyncStrategy,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ReferenceMount {
301 pub remote: String,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub description: Option<String>,
304 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
305 pub ref_name: Option<String>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(untagged)]
310pub enum ReferenceEntry {
311 Simple(String),
312 WithMetadata(ReferenceMount),
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct RepoConfigV2 {
317 pub version: String, #[serde(default)]
319 pub mount_dirs: MountDirsV2,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub thoughts_mount: Option<ThoughtsMount>,
322 #[serde(default)]
323 pub context_mounts: Vec<ContextMount>,
324 #[serde(default)]
325 pub references: Vec<ReferenceEntry>,
326}