Skip to main content

newgit_core/
tracker.rs

1use camino::{Utf8Path, Utf8PathBuf};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::error::{NewgitError, Result};
6
7/// A named, versioned lane of file content. Parsed from
8/// `.newgit/trackers/<name>.toml`; the name comes from the filename.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TrackerDefinition {
11    pub name: String,
12    pub audience: String,
13    pub storage: Storage,
14    /// Whether a source merge should carry this tracker binding with it.
15    pub merge_with_source: bool,
16    /// Workspace-relative paths this tracker owns. May be empty for lanes
17    /// that only receive deposits (e.g. `db-snapshots`).
18    pub paths: Vec<Utf8PathBuf>,
19    /// `sha256:<hex12>` of the definition file contents.
20    pub definition_rev: String,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "kebab-case")]
25pub enum Storage {
26    Local,
27    Remote,
28}
29
30/// On-disk shape (everything but the filename-derived name).
31#[derive(Debug, Serialize, Deserialize)]
32pub struct TrackerDefinitionFile {
33    audience: String,
34    storage: Storage,
35    #[serde(default)]
36    merge_with_source: bool,
37    #[serde(default)]
38    paths: Vec<Utf8PathBuf>,
39}
40
41impl TrackerDefinition {
42    pub fn new(
43        name: &str,
44        audience: String,
45        storage: Storage,
46        merge_with_source: bool,
47        paths: Vec<Utf8PathBuf>,
48    ) -> Result<Self> {
49        let mut definition = Self {
50            name: name.to_owned(),
51            audience,
52            storage,
53            merge_with_source,
54            paths,
55            definition_rev: String::new(),
56        };
57        definition.validate()?;
58        definition.definition_rev = definition.compute_definition_rev()?;
59        Ok(definition)
60    }
61
62    pub fn from_file(name: &str, path: &Utf8Path) -> Result<Self> {
63        let contents =
64            std::fs::read_to_string(path).map_err(|source| NewgitError::io(path, source))?;
65        let file: TrackerDefinitionFile =
66            toml::from_str(&contents).map_err(|source| NewgitError::TomlRead {
67                path: path.to_path_buf(),
68                source,
69            })?;
70
71        let digest = Sha256::digest(contents.as_bytes());
72        let hex: String = digest[..6]
73            .iter()
74            .map(|byte| format!("{byte:02x}"))
75            .collect();
76
77        let definition = Self {
78            name: name.to_owned(),
79            audience: file.audience,
80            storage: file.storage,
81            merge_with_source: file.merge_with_source,
82            paths: file.paths,
83            definition_rev: format!("sha256:{hex}"),
84        };
85        definition.validate()?;
86        Ok(definition)
87    }
88
89    fn validate(&self) -> Result<()> {
90        let audience_ok = matches!(self.audience.as_str(), "public" | "project-devs" | "user")
91            || self.audience.starts_with("user:");
92        if !audience_ok {
93            return Err(self.invalid(format!(
94                "audience `{}` is not `public`, `project-devs`, `user`, or `user:<name>`",
95                self.audience
96            )));
97        }
98
99        for path in &self.paths {
100            if path.is_absolute()
101                || path.as_str().is_empty()
102                || path.components().any(|c| c.as_str() == "..")
103            {
104                return Err(self.invalid(format!("path `{path}` must be workspace-relative")));
105            }
106            let first = path.components().next().map(|c| c.as_str().to_owned());
107            if matches!(first.as_deref(), Some(".git" | ".newgit")) {
108                return Err(self.invalid(format!(
109                    "path `{path}` may not reach into `{}`",
110                    first.unwrap_or_default()
111                )));
112            }
113        }
114        Ok(())
115    }
116
117    pub fn with_added_paths(&self, paths: &[Utf8PathBuf]) -> Result<Self> {
118        let mut updated = self.clone();
119        for path in paths {
120            if !updated.paths.iter().any(|existing| existing == path) {
121                updated.paths.push(path.clone());
122            }
123        }
124        updated.paths.sort();
125        updated.validate()?;
126        updated.definition_rev = updated.compute_definition_rev()?;
127        Ok(updated)
128    }
129
130    pub fn to_file(&self) -> TrackerDefinitionFile {
131        TrackerDefinitionFile {
132            audience: self.audience.clone(),
133            storage: self.storage,
134            merge_with_source: self.merge_with_source,
135            paths: self.paths.clone(),
136        }
137    }
138
139    fn compute_definition_rev(&self) -> Result<String> {
140        let contents =
141            toml::to_string_pretty(&self.to_file()).map_err(|source| NewgitError::TomlWrite {
142                label: format!("tracker `{}`", self.name),
143                source,
144            })?;
145        let digest = Sha256::digest(contents.as_bytes());
146        let hex: String = digest[..6]
147            .iter()
148            .map(|byte| format!("{byte:02x}"))
149            .collect();
150        Ok(format!("sha256:{hex}"))
151    }
152
153    fn invalid(&self, reason: String) -> NewgitError {
154        NewgitError::InvalidDefinition {
155            tracker: self.name.clone(),
156            reason,
157        }
158    }
159}
160
161/// Content lanes must be disjoint: no two trackers may own the same path or
162/// nest inside each other, or projection/restore order would matter.
163pub fn validate_disjoint(definitions: &[TrackerDefinition]) -> Result<()> {
164    for (index, left) in definitions.iter().enumerate() {
165        for right in &definitions[index + 1..] {
166            for left_path in &left.paths {
167                for right_path in &right.paths {
168                    if left_path.starts_with(right_path) || right_path.starts_with(left_path) {
169                        return Err(NewgitError::TrackerPathConflict {
170                            left: left.name.clone(),
171                            right: right.name.clone(),
172                            path: left_path.clone(),
173                        });
174                    }
175                }
176            }
177        }
178    }
179    Ok(())
180}
181
182/// Files a tracker owns inside a workspace, as (relative, absolute) pairs,
183/// sorted by relative path. Missing paths are simply absent.
184pub fn collect_owned_files(
185    workspace: &Utf8Path,
186    definition: &TrackerDefinition,
187) -> Result<Vec<(Utf8PathBuf, Utf8PathBuf)>> {
188    collect_files(workspace, &definition.paths)
189}
190
191/// Files under the given root-relative paths, as (relative, absolute) pairs,
192/// sorted by relative path. Missing paths are simply absent.
193pub fn collect_files(
194    root: &Utf8Path,
195    paths: &[Utf8PathBuf],
196) -> Result<Vec<(Utf8PathBuf, Utf8PathBuf)>> {
197    let mut files = Vec::new();
198    for relative in paths {
199        walk(root, relative, &mut files)?;
200    }
201    files.sort();
202    Ok(files)
203}
204
205/// Every file under `root`, as (root-relative, absolute) pairs, sorted.
206pub fn collect_all_files(root: &Utf8Path) -> Result<Vec<(Utf8PathBuf, Utf8PathBuf)>> {
207    let mut files = Vec::new();
208    for entry in std::fs::read_dir(root).map_err(|source| NewgitError::io(root, source))? {
209        let entry = entry.map_err(|source| NewgitError::io(root, source))?;
210        let name = entry.file_name();
211        let Some(name) = name.to_str() else {
212            return Err(NewgitError::NonUtf8Path(entry.path().display().to_string()));
213        };
214        walk(root, Utf8Path::new(name), &mut files)?;
215    }
216    files.sort();
217    Ok(files)
218}
219
220fn walk(
221    workspace: &Utf8Path,
222    relative: &Utf8Path,
223    files: &mut Vec<(Utf8PathBuf, Utf8PathBuf)>,
224) -> Result<()> {
225    let absolute = workspace.join(relative);
226    if absolute.is_file() {
227        files.push((relative.to_path_buf(), absolute));
228        return Ok(());
229    }
230    if !absolute.is_dir() {
231        return Ok(());
232    }
233    for entry in
234        std::fs::read_dir(&absolute).map_err(|source| NewgitError::io(&absolute, source))?
235    {
236        let entry = entry.map_err(|source| NewgitError::io(&absolute, source))?;
237        let name = entry.file_name();
238        let Some(name) = name.to_str() else {
239            return Err(NewgitError::NonUtf8Path(entry.path().display().to_string()));
240        };
241        walk(workspace, &relative.join(name), files)?;
242    }
243    Ok(())
244}
245
246/// Content-addressed revision of a set of files: `hex12` over sorted
247/// (path, length, bytes). Identical content dedupes to the same rev.
248pub fn content_rev(files: &[(Utf8PathBuf, Utf8PathBuf)]) -> Result<String> {
249    let mut hasher = Sha256::new();
250    for (relative, absolute) in files {
251        let bytes = std::fs::read(absolute).map_err(|source| NewgitError::io(absolute, source))?;
252        hasher.update(relative.as_str().as_bytes());
253        hasher.update([0]);
254        hasher.update((bytes.len() as u64).to_le_bytes());
255        hasher.update(&bytes);
256    }
257    let digest = hasher.finalize();
258    Ok(digest[..6]
259        .iter()
260        .map(|byte| format!("{byte:02x}"))
261        .collect())
262}
263
264#[cfg(test)]
265mod tests {
266    use camino::Utf8PathBuf;
267
268    use super::*;
269
270    fn definition(name: &str, paths: &[&str]) -> TrackerDefinition {
271        TrackerDefinition {
272            name: name.to_owned(),
273            audience: "project-devs".to_owned(),
274            storage: Storage::Local,
275            merge_with_source: false,
276            paths: paths.iter().map(Utf8PathBuf::from).collect(),
277            definition_rev: "sha256:000000000000".to_owned(),
278        }
279    }
280
281    #[test]
282    fn nested_paths_conflict() {
283        let defs = [
284            definition("a", &["src/generated"]),
285            definition("b", &["src/generated/sdk"]),
286        ];
287        assert!(matches!(
288            validate_disjoint(&defs),
289            Err(NewgitError::TrackerPathConflict { .. })
290        ));
291        let ok = [
292            definition("a", &["src/generated"]),
293            definition("b", &[".env.local"]),
294        ];
295        assert!(validate_disjoint(&ok).is_ok());
296    }
297}