use std::path::{Path, PathBuf};
use tracing::debug;
use crate::error::DcuError;
use crate::types::{
DependencySpec, ManifestKind, ManifestRef, PlannedUpdate, ResolvedVersion, TargetLevel,
};
pub trait ManifestHandler {
fn parse(&self, text: &str, path: &Path) -> Result<ParsedManifest, DcuError>;
fn apply_updates(&self, text: &str, updates: &[PlannedUpdate]) -> Result<String, DcuError>;
}
#[derive(Debug, Clone)]
pub struct ParsedManifest {
pub manifest_ref: ManifestRef,
pub original_text: String,
pub dependencies: Vec<DependencySpec>,
}
pub trait RegistryClient: Send + Sync {
fn resolve_version(
&self,
dep: &DependencySpec,
target: TargetLevel,
) -> impl std::future::Future<Output = Result<ResolvedVersion, DcuError>> + Send;
fn resolve_batch(
&self,
deps: &[DependencySpec],
target: TargetLevel,
) -> impl std::future::Future<Output = Vec<(usize, Result<ResolvedVersion, DcuError>)>> + Send;
}
pub struct Scanner;
impl Scanner {
#[must_use]
pub fn scan_dir(root: &Path) -> Vec<ManifestRef> {
let mut manifests = Vec::new();
let candidates = [
"package.json",
"Cargo.toml",
"pyproject.toml",
"action.yml",
"action.yaml",
];
for filename in &candidates {
let path = root.join(filename);
if path.is_file() {
if let Some(kind) = ManifestKind::from_path(&path) {
manifests.push(ManifestRef { path, kind });
}
}
}
let workflows_dir = root.join(".github").join("workflows");
if let Ok(entries) = std::fs::read_dir(&workflows_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(kind) = ManifestKind::from_path(&path) {
manifests.push(ManifestRef { path, kind });
}
}
manifests.sort_by(|a, b| a.path.cmp(&b.path));
}
manifests
}
pub fn from_path(path: &Path) -> Result<ManifestRef, DcuError> {
if !path.is_file() {
return Err(DcuError::NoManifest {
path: path.to_path_buf(),
});
}
let kind = ManifestKind::from_path(path).ok_or_else(|| DcuError::NoManifest {
path: path.to_path_buf(),
})?;
Ok(ManifestRef {
path: path.to_path_buf(),
kind,
})
}
#[must_use]
pub fn scan_deep(root: &Path) -> Vec<ManifestRef> {
use ignore::WalkBuilder;
let manifest_names: &[&str] = &[
"package.json",
"Cargo.toml",
"pyproject.toml",
"action.yml",
"action.yaml",
];
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
if name.starts_with('.') && name.as_ref() != "." && name.as_ref() != ".github" {
return false;
}
!matches!(
name.as_ref(),
"node_modules" | "target" | "dist" | "build" | "vendor" | "__pycache__"
)
})
.build();
let mut manifests = Vec::new();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let file_name = entry.file_name().to_string_lossy();
let is_workflow_yaml = matches!(
path.extension().and_then(|s| s.to_str()),
Some("yml" | "yaml")
) && path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
== Some("workflows")
&& path
.parent()
.and_then(Path::parent)
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
== Some(".github");
if manifest_names.contains(&file_name.as_ref()) || is_workflow_yaml {
let path = entry.into_path();
if let Some(kind) = ManifestKind::from_path(&path) {
debug!(path = %path.display(), kind = %kind, "deep scan: found manifest");
manifests.push(ManifestRef { path, kind });
}
}
}
manifests.sort_by(|a, b| a.path.cmp(&b.path));
manifests
}
pub fn discover(
root: &Path,
manifest_path: Option<&Path>,
deep: bool,
) -> Result<Vec<ManifestRef>, DcuError> {
if let Some(path) = manifest_path {
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
return Ok(vec![Self::from_path(&resolved)?]);
}
let manifests = if deep {
Self::scan_deep(root)
} else {
Self::scan_dir(root)
};
if manifests.is_empty() {
return Err(DcuError::NoManifest {
path: root.to_path_buf(),
});
}
Ok(manifests)
}
}
#[derive(Debug)]
pub struct ScanResult {
pub manifest_ref: ManifestRef,
pub path: PathBuf,
pub updates: Vec<PlannedUpdate>,
pub modified: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::{fixture, rstest};
use std::fs;
use tempfile::TempDir;
fn create_temp_manifest(dir: &Path, filename: &str, content: &str) {
fs::write(dir.join(filename), content).unwrap();
}
#[fixture]
fn tmp() -> TempDir {
TempDir::new().expect("create temp dir")
}
#[rstest]
#[case::package_json("package.json", r#"{"name":"test"}"#, ManifestKind::PackageJson)]
#[case::cargo_toml("Cargo.toml", "[package]\nname = \"test\"", ManifestKind::CargoToml)]
#[case::pyproject_toml(
"pyproject.toml",
"[project]\nname = \"test\"",
ManifestKind::PyProjectToml
)]
#[case::workflow_yml(
".github/workflows/CI.yml",
"jobs:\n test:\n runs-on: ubuntu-latest\n",
ManifestKind::GitHubWorkflow
)]
#[case::workflow_yaml(
".github/workflows/release.yaml",
"jobs: {}\n",
ManifestKind::GitHubWorkflow
)]
#[case::root_action_yml(
"action.yml",
"name: test\nruns:\n using: composite\n",
ManifestKind::GitHubWorkflow
)]
fn scan_dir_finds_single_manifest(
tmp: TempDir,
#[case] rel_path: &str,
#[case] content: &str,
#[case] kind: ManifestKind,
) {
let full = tmp.path().join(rel_path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&full, content).unwrap();
let manifests = Scanner::scan_dir(tmp.path());
assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].kind, kind);
let filename = std::path::Path::new(rel_path)
.file_name()
.expect("rel_path has a file name");
assert!(manifests[0].path.ends_with(filename));
}
#[test]
fn test_scan_dir_finds_all_three() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "package.json", "{}");
create_temp_manifest(dir.path(), "Cargo.toml", "[package]");
create_temp_manifest(dir.path(), "pyproject.toml", "[project]");
let manifests = Scanner::scan_dir(dir.path());
assert_eq!(manifests.len(), 3);
}
#[test]
fn test_scan_dir_empty() {
let dir = TempDir::new().unwrap();
let manifests = Scanner::scan_dir(dir.path());
assert!(manifests.is_empty());
}
#[test]
fn test_scan_dir_ignores_unknown_files() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "README.md", "# Hello");
create_temp_manifest(dir.path(), "build.gradle", "");
let manifests = Scanner::scan_dir(dir.path());
assert!(manifests.is_empty());
}
#[rstest]
#[case::valid_package_json("package.json", "{}", Some(ManifestKind::PackageJson))]
#[case::unknown_file("build.gradle", "", None)]
fn from_path_existing_file(
tmp: TempDir,
#[case] filename: &str,
#[case] content: &str,
#[case] expected: Option<ManifestKind>,
) {
create_temp_manifest(tmp.path(), filename, content);
let result = Scanner::from_path(&tmp.path().join(filename));
match expected {
Some(k) => assert_eq!(result.expect("from_path should succeed").kind, k),
None => assert!(result.is_err(), "expected Err for {filename}"),
}
}
#[test]
fn test_from_path_not_found() {
let result = Scanner::from_path(Path::new("/nonexistent/package.json"));
assert!(result.is_err());
}
#[test]
fn test_discover_with_explicit_path() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "package.json", "{}");
let result = Scanner::discover(dir.path(), Some(Path::new("package.json")), false);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 1);
}
#[test]
fn test_discover_auto_scan() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "package.json", "{}");
create_temp_manifest(dir.path(), "Cargo.toml", "[package]");
let result = Scanner::discover(dir.path(), None, false);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_discover_empty_dir_errors() {
let dir = TempDir::new().unwrap();
let result = Scanner::discover(dir.path(), None, false);
assert!(result.is_err());
}
#[test]
fn test_discover_deep_scan() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "package.json", "{}");
std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
create_temp_manifest(&dir.path().join("packages/app"), "package.json", "{}");
let result = Scanner::discover(dir.path(), None, true);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_scan_dir_workflow_files_sorted_alphabetically() {
let dir = TempDir::new().unwrap();
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
create_temp_manifest(&workflows, "z.yml", "jobs:");
create_temp_manifest(&workflows, "a.yml", "jobs:");
create_temp_manifest(&workflows, "m.yml", "jobs:");
let manifests = Scanner::scan_dir(dir.path());
assert_eq!(manifests.len(), 3);
assert!(manifests[0].path.ends_with("a.yml"));
assert!(manifests[1].path.ends_with("m.yml"));
assert!(manifests[2].path.ends_with("z.yml"));
}
#[test]
fn test_scan_dir_ignores_non_yml_files_in_workflows_dir() {
let dir = TempDir::new().unwrap();
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
create_temp_manifest(&workflows, "README.md", "# ignored");
create_temp_manifest(&workflows, "config.json", "{}");
create_temp_manifest(&workflows, "CI.yml", "jobs:");
let manifests = Scanner::scan_dir(dir.path());
assert_eq!(manifests.len(), 1);
assert!(manifests[0].path.ends_with("CI.yml"));
}
#[test]
fn test_scan_dir_combines_workflows_and_traditional_manifests() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "Cargo.toml", "[package]");
create_temp_manifest(dir.path(), "package.json", "{}");
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
create_temp_manifest(&workflows, "CI.yml", "jobs:");
let manifests = Scanner::scan_dir(dir.path());
assert_eq!(manifests.len(), 3);
let kinds: std::collections::HashSet<_> = manifests.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&ManifestKind::CargoToml));
assert!(kinds.contains(&ManifestKind::PackageJson));
assert!(kinds.contains(&ManifestKind::GitHubWorkflow));
}
#[test]
fn test_scan_dir_skips_subdirectories_in_workflows_dir() {
let dir = TempDir::new().unwrap();
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
std::fs::create_dir_all(workflows.join("templates")).unwrap();
create_temp_manifest(&workflows, "CI.yml", "jobs:");
let manifests = Scanner::scan_dir(dir.path());
assert_eq!(manifests.len(), 1);
assert!(manifests[0].path.ends_with("CI.yml"));
}
#[test]
fn test_scan_deep_prunes_excluded_dirs_but_keeps_nested() {
let dir = TempDir::new().unwrap();
let nm = dir.path().join("node_modules").join("foo");
std::fs::create_dir_all(&nm).unwrap();
create_temp_manifest(&nm, "package.json", "{}");
let app = dir.path().join("pkgs").join("app");
std::fs::create_dir_all(&app).unwrap();
create_temp_manifest(&app, "Cargo.toml", "[package]\nname = \"app\"");
let manifests = Scanner::scan_deep(dir.path());
assert!(
manifests
.iter()
.any(|m| m.path.ends_with("pkgs/app/Cargo.toml")
|| m.path.ends_with("pkgs\\app\\Cargo.toml")),
"expected pkgs/app/Cargo.toml in results: {:?}",
manifests.iter().map(|m| &m.path).collect::<Vec<_>>()
);
assert!(
!manifests
.iter()
.any(|m| m.path.to_string_lossy().contains("node_modules")),
"node_modules must be pruned: {:?}",
manifests.iter().map(|m| &m.path).collect::<Vec<_>>()
);
}
#[test]
fn test_discover_with_absolute_manifest_path() {
let dir = TempDir::new().unwrap();
create_temp_manifest(dir.path(), "Cargo.toml", "[package]\nname = \"x\"");
let abs = dir.path().join("Cargo.toml");
assert!(abs.is_absolute(), "tempfile path must be absolute");
let other_root = TempDir::new().unwrap();
let result = Scanner::discover(other_root.path(), Some(&abs), false)
.expect("absolute manifest path must resolve");
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, abs);
assert_eq!(result[0].kind, ManifestKind::CargoToml);
}
#[test]
fn test_scan_deep_walks_into_dot_github() {
let dir = TempDir::new().unwrap();
let workflows = dir.path().join(".github").join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
create_temp_manifest(&workflows, "CI.yml", "jobs:");
std::fs::create_dir_all(dir.path().join(".secret")).unwrap();
create_temp_manifest(&dir.path().join(".secret"), "package.json", "{}");
let manifests = Scanner::scan_deep(dir.path());
let workflow_count = manifests
.iter()
.filter(|m| m.kind == ManifestKind::GitHubWorkflow)
.count();
assert_eq!(workflow_count, 1, "must find workflow inside .github/");
let secret_count = manifests
.iter()
.filter(|m| m.path.to_string_lossy().contains(".secret"))
.count();
assert_eq!(secret_count, 0, "other hidden dirs must stay hidden");
}
}