1use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, TuffError};
15use crate::lockfile;
16
17pub const REPORT_SCHEMA: u32 = 1;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(rename_all = "camelCase")]
22pub struct Report {
23 pub schema: u32,
24 pub tuff_version: String,
25 pub generated_at: String,
27 pub project: ProjectIdentity,
28 pub lockfile: serde_json::Value,
30 pub check: serde_json::Value,
32 pub outdated: Option<serde_json::Value>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "camelCase")]
40pub struct ProjectIdentity {
41 pub repository: String,
42 pub path: String,
44 pub name: String,
45 pub commit: Option<String>,
46 pub branch: Option<String>,
47 pub dirty: bool,
49}
50
51pub fn build_report(
56 project_root: &Path,
57 project_name: Option<&str>,
58 outdated: Option<serde_json::Value>,
59) -> Result<Report> {
60 let lock = lockfile::require_lockfile(project_root)?;
61 let check = crate::check::run_checks(project_root, crate::check::CheckScope::Project)?;
62 let lockfile: serde_json::Value = serde_json::from_str(&lockfile::render_lockfile(&lock)?)?;
63 Ok(Report {
64 schema: REPORT_SCHEMA,
65 tuff_version: env!("CARGO_PKG_VERSION").to_string(),
66 generated_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
67 project: project_identity(project_root, project_name)?,
68 lockfile,
69 check: serde_json::to_value(check)?,
70 outdated,
71 })
72}
73
74pub fn project_identity(
76 project_root: &Path,
77 project_name: Option<&str>,
78) -> Result<ProjectIdentity> {
79 let project_root = project_root.canonicalize()?;
80 let folder_name = |path: &Path| {
81 path.file_name()
82 .map(|name| name.to_string_lossy().into_owned())
83 .unwrap_or_else(|| "project".to_string())
84 };
85 let missing_name = || {
86 TuffError::usage(format!(
87 "{} is not in a git repository with an 'origin' remote, so the report has no repository to name",
88 project_root.display()
89 ))
90 .with_hint("pass --project <name> to name it")
91 };
92
93 let Ok(repo) = git2::Repository::discover(&project_root) else {
94 let repository = project_name.ok_or_else(missing_name)?.to_string();
95 return Ok(ProjectIdentity {
96 repository,
97 path: ".".to_string(),
98 name: folder_name(&project_root),
99 commit: None,
100 branch: None,
101 dirty: false,
102 });
103 };
104 let workdir = repo
105 .workdir()
106 .ok_or_else(|| TuffError::usage("a bare git repository has no project folder"))?
107 .canonicalize()?;
108 let relative = project_root
109 .strip_prefix(&workdir)
110 .map(Path::to_path_buf)
111 .unwrap_or_default();
112 let path = if relative.as_os_str().is_empty() {
113 ".".to_string()
114 } else {
115 relative
116 .components()
117 .map(|part| part.as_os_str().to_string_lossy().into_owned())
118 .collect::<Vec<_>>()
119 .join("/")
120 };
121
122 let origin = repo
123 .find_remote("origin")
124 .ok()
125 .and_then(|remote| remote.url().map(normalize_remote));
126 let repository = match (project_name, origin) {
127 (Some(name), _) => name.to_string(),
128 (None, Some(origin)) => origin,
129 (None, None) => return Err(missing_name()),
130 };
131
132 let head = repo.head().ok();
133 let commit = head
134 .as_ref()
135 .and_then(|head| head.peel_to_commit().ok())
136 .map(|commit| commit.id().to_string());
137 let branch = head
138 .as_ref()
139 .filter(|head| head.is_branch())
140 .and_then(|head| head.shorthand().map(str::to_string));
141
142 let mut options = git2::StatusOptions::new();
143 options
144 .include_untracked(true)
145 .recurse_untracked_dirs(false);
146 if path != "." {
147 options.pathspec(&path);
148 }
149 let dirty = repo
150 .statuses(Some(&mut options))
151 .map(|statuses| !statuses.is_empty())
152 .unwrap_or(false);
153
154 Ok(ProjectIdentity {
155 repository,
156 name: folder_name(&project_root),
157 path,
158 commit,
159 branch,
160 dirty,
161 })
162}
163
164pub fn normalize_remote(url: &str) -> String {
169 let trimmed = url.trim().trim_end_matches('/');
170 let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
171 let (host, path) = if let Some((_, rest)) = trimmed.split_once("://") {
172 match rest.split_once('/') {
173 Some((authority, path)) => (authority, path),
174 None => (rest, ""),
175 }
176 } else if !trimmed.starts_with('/')
177 && !trimmed.starts_with('.')
178 && let Some((authority, path)) = trimmed.split_once(':')
179 {
180 (authority, path)
181 } else {
182 return trimmed.to_string();
183 };
184 let host = host.rsplit('@').next().unwrap_or(host);
185 let host = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
186 let path = path.trim_start_matches('/');
187 if path.is_empty() {
188 host
189 } else {
190 format!("{host}/{path}")
191 }
192}
193
194pub fn find_projects(root: &Path) -> Result<Vec<PathBuf>> {
198 let mut found = Vec::new();
199 let mut pending = vec![root.to_path_buf()];
200 while let Some(dir) = pending.pop() {
201 if lockfile::project_lockfile(&dir).is_file() {
202 found.push(dir.clone());
203 }
204 for entry in std::fs::read_dir(&dir)? {
205 let entry = entry?;
206 if !entry.file_type()?.is_dir() {
207 continue;
208 }
209 let name = entry.file_name();
210 let name = name.to_string_lossy();
211 if name.starts_with('.') || name == "node_modules" || name == "target" {
212 continue;
213 }
214 pending.push(entry.path());
215 }
216 }
217 found.sort();
218 Ok(found)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn remotes_normalise_to_one_name() {
227 for url in [
228 "git@github.com:acme/agents.git",
229 "https://github.com/acme/agents.git",
230 "https://x-access-token:secret@github.com/acme/agents",
231 "ssh://git@GitHub.com:22/acme/agents.git/",
232 "http://github.com/acme/agents",
233 ] {
234 assert_eq!(normalize_remote(url), "github.com/acme/agents", "{url}");
235 }
236 assert_eq!(normalize_remote("/srv/git/agents.git"), "/srv/git/agents");
237 assert_eq!(normalize_remote("../agents"), "../agents");
238 }
239
240 #[test]
241 fn projects_are_found_below_the_root_and_hidden_folders_are_skipped() {
242 let temp = tempfile::tempdir().unwrap();
243 let root = temp.path();
244 for dir in [
245 "",
246 "apps/support-agent",
247 "apps/billing-agent",
248 "node_modules/pkg",
249 ".claude/nested",
250 "target/debug",
251 ] {
252 let path = root.join(dir);
253 std::fs::create_dir_all(&path).unwrap();
254 std::fs::write(path.join("tuff.lock"), "{}").unwrap();
255 }
256 std::fs::create_dir_all(root.join("apps/no-lock")).unwrap();
257 let found: Vec<_> = find_projects(root)
258 .unwrap()
259 .into_iter()
260 .map(|path| {
261 path.strip_prefix(root)
262 .unwrap()
263 .to_string_lossy()
264 .into_owned()
265 })
266 .collect();
267 assert_eq!(found, ["", "apps/billing-agent", "apps/support-agent"]);
268 }
269
270 #[test]
271 fn a_project_outside_git_needs_a_name() {
272 let temp = tempfile::tempdir().unwrap();
273 let error = project_identity(temp.path(), None).unwrap_err();
274 assert!(
275 error.to_string().contains("--project")
276 || error.hint().is_some_and(|hint| hint.contains("--project")),
277 "{error}"
278 );
279 let identity = project_identity(temp.path(), Some("local-agents")).unwrap();
280 assert_eq!(identity.repository, "local-agents");
281 assert_eq!(identity.path, ".");
282 assert!(identity.commit.is_none());
283 }
284
285 #[test]
286 fn a_monorepo_project_is_named_by_its_origin_and_path() {
287 let temp = tempfile::tempdir().unwrap();
288 let root = temp.path();
289 let repo = git2::Repository::init(root).unwrap();
290 repo.remote("origin", "git@github.com:acme/agents.git")
291 .unwrap();
292 let app = root.join("apps/billing-agent");
293 std::fs::create_dir_all(&app).unwrap();
294 std::fs::write(app.join("README.md"), "x").unwrap();
295 let mut index = repo.index().unwrap();
296 index
297 .add_path(Path::new("apps/billing-agent/README.md"))
298 .unwrap();
299 index.write().unwrap();
300 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
301 let signature = git2::Signature::now("t", "t@example.test").unwrap();
302 let commit = repo
303 .commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
304 .unwrap();
305
306 let identity = project_identity(&app, None).unwrap();
307 assert_eq!(identity.repository, "github.com/acme/agents");
308 assert_eq!(identity.path, "apps/billing-agent");
309 assert_eq!(identity.name, "billing-agent");
310 assert_eq!(
311 identity.commit.as_deref(),
312 Some(commit.to_string().as_str())
313 );
314 assert!(identity.branch.is_some());
315 assert!(!identity.dirty);
316
317 std::fs::write(app.join("notes.md"), "y").unwrap();
318 assert!(project_identity(&app, None).unwrap().dirty);
319 let root_identity = project_identity(root, None).unwrap();
320 assert_eq!(root_identity.path, ".");
321 }
322}