use crate::discover::DiscoverReport;
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ProjectContext {
pub project_dir: String,
pub has_jarvy_toml: bool,
pub top_level: Vec<String>,
pub markers: Vec<String>,
pub git: Option<GitStatus>,
pub discover: DiscoverReport,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct GitStatus {
pub branch: Option<String>,
pub dirty: bool,
}
const MARKER_FILES: &[&str] = &[
"Cargo.toml",
"package.json",
"pyproject.toml",
"go.mod",
"Gemfile",
"Dockerfile",
"Makefile",
"Justfile",
".nvmrc",
".python-version",
"rust-toolchain.toml",
"pnpm-workspace.yaml",
"turbo.json",
];
pub fn build(project_dir: &Path, discover: DiscoverReport) -> ProjectContext {
let top_level = list_top_level(project_dir);
let markers = find_markers(project_dir, MARKER_FILES, 3);
let git = read_git_status(project_dir);
let has_jarvy_toml = project_dir.join("jarvy.toml").exists();
ProjectContext {
project_dir: project_dir.to_string_lossy().into_owned(),
has_jarvy_toml,
top_level,
markers,
git,
discover,
}
}
fn list_top_level(dir: &Path) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut entries: Vec<String> = rd
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|name| !matches!(name.as_str(), "node_modules" | "target" | ".git"))
.collect();
entries.sort_by_key(|name| (name.starts_with('.'), name.clone()));
entries
}
fn find_markers(root: &Path, markers: &[&str], max_depth: usize) -> Vec<String> {
let marker_set: HashSet<&str> = markers.iter().copied().collect();
let mut found: HashSet<String> = HashSet::new();
walk(root, root, &marker_set, &mut found, max_depth);
let mut out: Vec<String> = found.into_iter().collect();
out.sort();
out
}
fn walk(
root: &Path,
dir: &Path,
markers: &HashSet<&str>,
found: &mut HashSet<String>,
remaining_depth: usize,
) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
let Some(name_os) = path.file_name() else {
continue;
};
let name = name_os.to_string_lossy();
if path.is_dir() {
if matches!(
name.as_ref(),
"node_modules" | "target" | ".git" | "vendor" | "dist" | "build"
) {
continue;
}
if remaining_depth > 0 {
walk(root, &path, markers, found, remaining_depth - 1);
}
} else if markers.contains(name.as_ref()) {
if let Ok(rel) = path.strip_prefix(root) {
found.insert(rel.to_string_lossy().into_owned());
} else {
found.insert(name.into_owned());
}
}
}
}
fn read_git_status(project_dir: &Path) -> Option<GitStatus> {
let git_dir = project_dir.join(".git");
if !git_dir.exists() {
return None;
}
let branch = std::fs::read_to_string(git_dir.join("HEAD"))
.ok()
.and_then(|s| {
s.strip_prefix("ref: refs/heads/")
.map(|b| b.trim().to_string())
});
Some(GitStatus {
branch,
dirty: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::DiscoverReport;
use std::fs;
use tempfile::tempdir;
fn empty_report(dir: &Path) -> DiscoverReport {
let already = std::collections::HashSet::new();
let known = std::collections::HashSet::new();
crate::discover::analyze(dir, &already, &known)
}
#[test]
fn detects_jarvy_toml_presence() {
let tmp = tempdir().unwrap();
let report = empty_report(tmp.path());
let ctx_a = build(tmp.path(), report.clone());
assert!(!ctx_a.has_jarvy_toml);
fs::write(tmp.path().join("jarvy.toml"), "").unwrap();
let ctx_b = build(tmp.path(), report);
assert!(ctx_b.has_jarvy_toml);
}
#[test]
fn surfaces_markers_in_root_and_subdir() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::create_dir(tmp.path().join("apps")).unwrap();
fs::write(tmp.path().join("apps").join("package.json"), "").unwrap();
let ctx = build(tmp.path(), empty_report(tmp.path()));
assert!(ctx.markers.iter().any(|m| m == "Cargo.toml"));
assert!(
ctx.markers.iter().any(|m| m.ends_with("package.json")),
"got: {:?}",
ctx.markers
);
}
#[test]
fn ignores_noise_dirs() {
let tmp = tempdir().unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
fs::write(tmp.path().join("node_modules").join("package.json"), "").unwrap();
let ctx = build(tmp.path(), empty_report(tmp.path()));
assert!(
!ctx.markers.iter().any(|m| m.contains("node_modules")),
"node_modules must not appear in markers: {:?}",
ctx.markers
);
}
#[test]
fn git_status_none_when_not_a_repo() {
let tmp = tempdir().unwrap();
let ctx = build(tmp.path(), empty_report(tmp.path()));
assert!(ctx.git.is_none());
}
#[test]
fn json_roundtrip_stable_shape() {
let tmp = tempdir().unwrap();
let ctx = build(tmp.path(), empty_report(tmp.path()));
let json = serde_json::to_value(&ctx).unwrap();
for key in &[
"project_dir",
"has_jarvy_toml",
"top_level",
"markers",
"git",
"discover",
] {
assert!(json.get(key).is_some(), "envelope missing key: {key}");
}
}
}