Skip to main content

syncor_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Result, SyncorError};
7use crate::link::{LinkId, LinkInfo};
8
9// ---------------------------------------------------------------------------
10// SyncorPaths
11// ---------------------------------------------------------------------------
12
13/// Canonical filesystem layout for Syncor's config and data directories.
14///
15/// Follows an XDG-like convention:
16///   config: $HOME/.config/syncor/
17///   data:   $HOME/.local/share/syncor/
18#[derive(Debug, Clone)]
19pub struct SyncorPaths {
20    config_dir: PathBuf,
21    data_dir: PathBuf,
22}
23
24impl SyncorPaths {
25    /// Derive paths from the system home directory (uses `dirs` crate).
26    pub fn new() -> Self {
27        let home = dirs::home_dir().expect("unable to resolve home directory");
28        Self::with_home(&home)
29    }
30
31    /// Derive paths from an explicit home directory — useful in tests.
32    pub fn with_home(home: &Path) -> Self {
33        let config_dir = home.join(".config").join("syncor");
34        let data_dir = home.join(".local").join("share").join("syncor");
35        Self {
36            config_dir,
37            data_dir,
38        }
39    }
40
41    // --- Config-dir paths ---
42
43    pub fn config_dir(&self) -> &Path {
44        &self.config_dir
45    }
46
47    pub fn config_file(&self) -> PathBuf {
48        self.config_dir.join("config.toml")
49    }
50
51    pub fn links_file(&self) -> PathBuf {
52        self.config_dir.join("links.toml")
53    }
54
55    // --- Data-dir paths ---
56
57    pub fn data_dir(&self) -> &Path {
58        &self.data_dir
59    }
60
61    /// Unix-domain socket for daemon IPC.
62    pub fn socket_path(&self) -> PathBuf {
63        self.data_dir.join("syncor.sock")
64    }
65
66    /// PID file used to track the running daemon.
67    pub fn pid_file(&self) -> PathBuf {
68        self.data_dir.join("syncor.pid")
69    }
70
71    /// Log file for the daemon.
72    pub fn log_file(&self) -> PathBuf {
73        self.data_dir.join("syncor.log")
74    }
75
76    /// Root directory that holds per-link working directories.
77    pub fn link_dir(&self) -> PathBuf {
78        self.data_dir.join("links")
79    }
80
81    /// Working directory for a specific link's repo checkout.
82    pub fn link_repo_dir(&self, link_id: &LinkId) -> PathBuf {
83        self.link_dir().join(link_id.as_str())
84    }
85
86    /// SQLite database that holds link state / checkpoints.
87    pub fn link_state_db(&self) -> PathBuf {
88        self.data_dir.join("state.db")
89    }
90
91    /// Advisory lock file for a specific link.
92    pub fn link_lock_file(&self, link_id: &LinkId) -> PathBuf {
93        self.link_dir().join(format!("{}.lock", link_id.as_str()))
94    }
95
96    /// Create all required directories, returning an error on failure.
97    pub fn ensure_dirs(&self) -> Result<()> {
98        std::fs::create_dir_all(&self.config_dir)?;
99        std::fs::create_dir_all(&self.data_dir)?;
100        std::fs::create_dir_all(self.link_dir())?;
101        Ok(())
102    }
103}
104
105impl Default for SyncorPaths {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111// ---------------------------------------------------------------------------
112// SyncorConfig
113// ---------------------------------------------------------------------------
114
115/// Top-level application configuration (stored in `config.toml`).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SyncorConfig {
118    /// How long (seconds) to debounce filesystem events before acting.
119    pub debounce_secs: u64,
120    /// Default poll interval (seconds) for links that don't override it.
121    pub default_poll_interval_secs: u64,
122}
123
124impl Default for SyncorConfig {
125    fn default() -> Self {
126        Self {
127            debounce_secs: 2,
128            default_poll_interval_secs: 60,
129        }
130    }
131}
132
133impl SyncorConfig {
134    /// Load config from a TOML file.  If the file does not exist the default
135    /// configuration is returned.
136    pub fn load(path: &Path) -> Result<Self> {
137        if !path.exists() {
138            return Ok(Self::default());
139        }
140        let raw = std::fs::read_to_string(path)?;
141        let cfg: Self = toml::from_str(&raw).map_err(|e| SyncorError::Config(e.to_string()))?;
142        Ok(cfg)
143    }
144
145    /// Persist the configuration to a TOML file, creating parent dirs as needed.
146    pub fn save(&self, path: &Path) -> Result<()> {
147        if let Some(parent) = path.parent() {
148            std::fs::create_dir_all(parent)?;
149        }
150        let raw = toml::to_string_pretty(self).map_err(|e| SyncorError::Config(e.to_string()))?;
151        std::fs::write(path, raw)?;
152        Ok(())
153    }
154}
155
156// ---------------------------------------------------------------------------
157// LinksRegistry
158// ---------------------------------------------------------------------------
159
160/// On-disk representation of the links file.
161#[derive(Debug, Default, Serialize, Deserialize)]
162struct LinksFile {
163    #[serde(default)]
164    links: Vec<LinkInfo>,
165}
166
167/// In-memory registry of all configured links, backed by `links.toml`.
168#[derive(Debug, Default)]
169pub struct LinksRegistry {
170    /// Primary index: id → info.
171    by_id: HashMap<String, LinkInfo>,
172}
173
174impl LinksRegistry {
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Load from a TOML file.  Returns an empty registry if the file does not
180    /// exist.
181    pub fn load(path: &Path) -> Result<Self> {
182        if !path.exists() {
183            return Ok(Self::new());
184        }
185        let raw = std::fs::read_to_string(path)?;
186        let file: LinksFile =
187            toml::from_str(&raw).map_err(|e| SyncorError::Config(e.to_string()))?;
188        let mut registry = Self::new();
189        for info in file.links {
190            registry.by_id.insert(info.id.as_str().to_owned(), info);
191        }
192        Ok(registry)
193    }
194
195    /// Persist the registry to a TOML file.
196    pub fn save(&self, path: &Path) -> Result<()> {
197        if let Some(parent) = path.parent() {
198            std::fs::create_dir_all(parent)?;
199        }
200        let mut links: Vec<&LinkInfo> = self.by_id.values().collect();
201        // Stable ordering for deterministic output.
202        links.sort_by_key(|l| l.id.as_str());
203        let file = LinksFile {
204            links: links.into_iter().cloned().collect(),
205        };
206        let raw = toml::to_string_pretty(&file).map_err(|e| SyncorError::Config(e.to_string()))?;
207        std::fs::write(path, raw)?;
208        Ok(())
209    }
210
211    /// Add a new link.  Returns an error if:
212    /// - A link with the same id already exists.
213    /// - Another link already points at the same `local_dir` (one-dir one-link).
214    pub fn add(&mut self, info: LinkInfo) -> Result<()> {
215        // Check id uniqueness.
216        if self.by_id.contains_key(info.id.as_str()) {
217            return Err(SyncorError::LinkAlreadyExists(format!(
218                "id {} already registered",
219                info.id
220            )));
221        }
222        // Enforce the one-dir / one-link constraint.
223        if self.get_by_dir(&info.local_dir).is_some() {
224            return Err(SyncorError::LinkAlreadyExists(format!(
225                "directory {} is already managed by another link",
226                info.local_dir.display()
227            )));
228        }
229        self.by_id.insert(info.id.as_str().to_owned(), info);
230        Ok(())
231    }
232
233    /// Remove a link by its id.  Returns an error if not found.
234    pub fn remove(&mut self, id: &LinkId) -> Result<()> {
235        self.by_id
236            .remove(id.as_str())
237            .ok_or_else(|| SyncorError::LinkNotFound(id.to_string()))?;
238        Ok(())
239    }
240
241    /// Look up a link by its human-readable name.
242    pub fn get_by_name(&self, name: &str) -> Option<&LinkInfo> {
243        self.by_id.values().find(|l| l.name == name)
244    }
245
246    /// Look up a link by its `local_dir`.
247    pub fn get_by_dir(&self, dir: &Path) -> Option<&LinkInfo> {
248        self.by_id.values().find(|l| l.local_dir == dir)
249    }
250
251    /// Look up a link by its `LinkId`.
252    pub fn get_by_id(&self, id: &LinkId) -> Option<&LinkInfo> {
253        self.by_id.get(id.as_str())
254    }
255
256    /// Iterate over all registered links.
257    pub fn iter(&self) -> impl Iterator<Item = &LinkInfo> {
258        self.by_id.values()
259    }
260}