1use camino::{Utf8Path, Utf8PathBuf};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::error::{NewgitError, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TrackerDefinition {
11 pub name: String,
12 pub audience: String,
13 pub storage: Storage,
14 pub merge_with_source: bool,
16 pub paths: Vec<Utf8PathBuf>,
19 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#[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
161pub 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
182pub fn collect_owned_files(
185 workspace: &Utf8Path,
186 definition: &TrackerDefinition,
187) -> Result<Vec<(Utf8PathBuf, Utf8PathBuf)>> {
188 collect_files(workspace, &definition.paths)
189}
190
191pub 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
205pub 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
246pub 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}