1use camino::{Utf8Path, Utf8PathBuf};
2
3use crate::error::{NewgitError, Result};
4use crate::tracker::{TrackerDefinition, collect_files};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct ExportFilter {
16 pub includes: Vec<Utf8PathBuf>,
18 pub excludes: Vec<Utf8PathBuf>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Reason {
25 Source,
27 PublicTracker,
29 Forced,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ExportedFile {
35 pub path: Utf8PathBuf,
37 pub reason: Reason,
38 pub tracker: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct TrackerDisposition {
46 pub name: String,
47 pub audience: String,
48 pub included: usize,
49 pub withheld: Vec<Utf8PathBuf>,
51}
52
53impl TrackerDisposition {
54 pub fn is_public(&self) -> bool {
55 is_public(&self.audience)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ExportPlan {
61 pub files: Vec<ExportedFile>,
62 pub trackers: Vec<TrackerDisposition>,
63 pub excluded: Vec<Utf8PathBuf>,
65}
66
67impl ExportPlan {
68 pub fn count(&self, reason: Reason) -> usize {
69 self.files
70 .iter()
71 .filter(|file| file.reason == reason)
72 .count()
73 }
74}
75
76pub fn is_public(audience: &str) -> bool {
79 audience == "public"
80}
81
82pub fn plan(
86 workspace: &Utf8Path,
87 source_files: &[Utf8PathBuf],
88 trackers: &[TrackerDefinition],
89 filter: &ExportFilter,
90) -> Result<ExportPlan> {
91 let mut files: Vec<ExportedFile> = Vec::new();
92 let mut excluded: Vec<Utf8PathBuf> = Vec::new();
93
94 for path in source_files {
95 if !workspace.join(path).is_file() {
98 continue;
99 }
100 if covers(&filter.excludes, path) {
101 excluded.push(path.clone());
102 continue;
103 }
104 files.push(ExportedFile {
105 path: path.clone(),
106 reason: Reason::Source,
107 tracker: None,
108 });
109 }
110
111 let mut dispositions = Vec::new();
112 for tracker in trackers {
113 let public = is_public(&tracker.audience);
114 let mut included = 0;
115 let mut withheld = Vec::new();
116
117 for (relative, _) in collect_files(workspace, &tracker.paths)? {
118 if covers(&filter.excludes, &relative) {
119 excluded.push(relative);
120 continue;
121 }
122 let forced = covers(&filter.includes, &relative);
123 if !public && !forced {
124 withheld.push(relative);
125 continue;
126 }
127 included += 1;
128 files.push(ExportedFile {
129 path: relative,
130 reason: if public {
131 Reason::PublicTracker
132 } else {
133 Reason::Forced
134 },
135 tracker: Some(tracker.name.clone()),
136 });
137 }
138
139 dispositions.push(TrackerDisposition {
140 name: tracker.name.clone(),
141 audience: tracker.audience.clone(),
142 included,
143 withheld,
144 });
145 }
146
147 for include in &filter.includes {
151 for (relative, _) in collect_files(workspace, std::slice::from_ref(include))? {
152 if covers(&filter.excludes, &relative) || files.iter().any(|f| f.path == relative) {
153 continue;
154 }
155 files.push(ExportedFile {
156 path: relative,
157 reason: Reason::Forced,
158 tracker: None,
159 });
160 }
161 }
162
163 files.sort_by(|left, right| left.path.cmp(&right.path));
164 excluded.sort();
165 excluded.dedup();
166
167 Ok(ExportPlan {
168 files,
169 trackers: dispositions,
170 excluded,
171 })
172}
173
174fn covers(paths: &[Utf8PathBuf], candidate: &Utf8Path) -> bool {
176 paths.iter().any(|path| candidate.starts_with(path))
177}
178
179pub fn prepare_destination(destination: &Utf8Path) -> Result<()> {
183 if !destination.exists() {
184 return Ok(());
185 }
186 if !destination.is_dir() {
187 return Err(NewgitError::Unsupported(format!(
188 "{destination} exists and is not a directory"
189 )));
190 }
191 let mut entries =
192 std::fs::read_dir(destination).map_err(|source| NewgitError::io(destination, source))?;
193 if entries.next().is_some() {
194 return Err(NewgitError::Unsupported(format!(
195 "{destination} is not empty; export writes a fresh repository, so pass an empty or \
196 nonexistent path"
197 )));
198 }
199 Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204 use camino::Utf8PathBuf;
205
206 use super::*;
207 use crate::tracker::Storage;
208
209 fn tracker(name: &str, audience: &str, paths: &[&str]) -> TrackerDefinition {
210 TrackerDefinition {
211 name: name.to_owned(),
212 audience: audience.to_owned(),
213 storage: Storage::Local,
214 merge_with_source: false,
215 paths: paths.iter().map(Utf8PathBuf::from).collect(),
216 definition_rev: "sha256:000000000000".to_owned(),
217 }
218 }
219
220 fn workspace() -> (tempfile::TempDir, Utf8PathBuf) {
222 let temp = tempfile::tempdir().expect("tempdir");
223 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
224 std::fs::write(root.join("main.rs"), "fn main() {}").expect("write");
225 std::fs::write(root.join(".env.local"), "SECRET=1").expect("write");
226 std::fs::create_dir_all(root.join("src/generated")).expect("mkdir");
227 std::fs::write(root.join("src/generated/api.ts"), "export {}").expect("write");
228 (temp, root)
229 }
230
231 #[test]
232 fn audience_withholds_by_default_and_flags_override() {
233 let (_guard, root) = workspace();
234 let source = vec![Utf8PathBuf::from("main.rs")];
235 let trackers = [
236 tracker("runtime-env", "user", &[".env.local"]),
237 tracker("generated-sdk", "public", &["src/generated"]),
238 ];
239
240 let default = plan(&root, &source, &trackers, &ExportFilter::default()).expect("plan");
241 let paths: Vec<&str> = default.files.iter().map(|f| f.path.as_str()).collect();
242 assert_eq!(paths, ["main.rs", "src/generated/api.ts"]);
243 assert_eq!(default.count(Reason::Source), 1);
244 assert_eq!(default.count(Reason::PublicTracker), 1);
245
246 let env = default
247 .trackers
248 .iter()
249 .find(|t| t.name == "runtime-env")
250 .expect("runtime-env");
251 assert_eq!(env.withheld, [Utf8PathBuf::from(".env.local")]);
252 assert_eq!(env.included, 0);
253
254 let forced = plan(
256 &root,
257 &source,
258 &trackers,
259 &ExportFilter {
260 includes: vec![Utf8PathBuf::from(".env.local")],
261 excludes: Vec::new(),
262 },
263 )
264 .expect("plan");
265 assert_eq!(forced.count(Reason::Forced), 1);
266 assert!(
267 forced
268 .trackers
269 .iter()
270 .find(|t| t.name == "runtime-env")
271 .expect("runtime-env")
272 .withheld
273 .is_empty()
274 );
275 }
276
277 #[test]
278 fn exclude_wins_over_source_and_include() {
279 let (_guard, root) = workspace();
280 let source = vec![Utf8PathBuf::from("main.rs")];
281 let trackers = [tracker("generated-sdk", "public", &["src/generated"])];
282
283 let filtered = plan(
284 &root,
285 &source,
286 &trackers,
287 &ExportFilter {
288 includes: vec![Utf8PathBuf::from("src/generated")],
289 excludes: vec![
290 Utf8PathBuf::from("main.rs"),
291 Utf8PathBuf::from("src/generated"),
292 ],
293 },
294 )
295 .expect("plan");
296
297 assert!(
298 filtered.files.is_empty(),
299 "exclude is applied last and wins"
300 );
301 assert_eq!(
302 filtered.excluded,
303 [
304 Utf8PathBuf::from("main.rs"),
305 Utf8PathBuf::from("src/generated/api.ts")
306 ]
307 );
308 }
309
310 #[test]
311 fn destination_must_be_absent_or_empty() {
312 let (_guard, root) = workspace();
313 assert!(prepare_destination(&root.join("fresh")).is_ok());
314 std::fs::create_dir_all(root.join("empty")).expect("mkdir");
315 assert!(prepare_destination(&root.join("empty")).is_ok());
316 assert!(matches!(
317 prepare_destination(&root),
318 Err(NewgitError::Unsupported(_))
319 ));
320 }
321}