Skip to main content

tuff_core/
report.rs

1//! Dashboard reports (RFC-108): one JSON document describing one project at
2//! one commit, which `tuff dashboard publish` sends to a dashboard server.
3//!
4//! A report is the project's lockfile plus what only the working tree can
5//! say: whether the installed files still match (`tuff check`) and,
6//! optionally, whether newer versions exist (`tuff outdated`). The schema
7//! is a published format like the lockfile, and a server refuses a
8//! `schema` it does not know.
9
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, TuffError};
15use crate::lockfile;
16
17/// The report schema this Tuff writes and reads.
18pub 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    /// RFC 3339, UTC.
26    pub generated_at: String,
27    pub project: ProjectIdentity,
28    /// `tuff.lock` in the current schema.
29    pub lockfile: serde_json::Value,
30    /// `tuff check --json` for the project scope.
31    pub check: serde_json::Value,
32    /// `tuff outdated --json` for the project scope, when asked for.
33    pub outdated: Option<serde_json::Value>,
34}
35
36/// Which project a report describes (RFC-108 D3). `repository` and `path`
37/// together identify it across reports; `name` is a label.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "camelCase")]
40pub struct ProjectIdentity {
41    pub repository: String,
42    /// The project folder relative to the repository root, `.` for the root.
43    pub path: String,
44    pub name: String,
45    pub commit: Option<String>,
46    pub branch: Option<String>,
47    /// Whether the project folder has uncommitted changes.
48    pub dirty: bool,
49}
50
51/// Build the report for the project at `project_root`, whose `tuff.lock`
52/// must exist. `project_name`, when given, replaces the repository value,
53/// and is required outside git or without an `origin` remote. `outdated` is
54/// computed by the caller, which owns the network.
55pub 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
74/// Work out where a project sits in its repository (RFC-108 D3).
75pub 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
164/// A git remote URL as one stable repository name: no scheme, credentials,
165/// port, or trailing `.git`, and a lowercase host. `git@github.com:acme/x.git`
166/// and `https://token@github.com/acme/x` both become `github.com/acme/x`.
167/// A local path is kept, without a trailing `.git`.
168pub 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
194/// Every folder under `root` with a `tuff.lock`, `root` included, in path
195/// order. `.git`, `node_modules`, `target`, and other hidden folders are
196/// not searched.
197pub 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}