From eef78d2003513f221d9e97ca1a946de26b3ea36c Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Thu, 9 Jul 2026 00:57:55 +0200 Subject: [PATCH] feat: extract commands::services from dashboard scan Lift the dashboard scan tree into a rendering-agnostic lib module so the axum dashboard and a future TUI consume the same services. The one binary-crate coupling (watched_locations) is injected into build_view. commands::view unchanged. No behavioral change to the dashboard. --- CHANGELOG.md | 1 + src/assemble/tests.rs | 8 +- src/cli/dashboard/mod.rs | 2 +- src/cli/dashboard/server.rs | 7 +- src/commands.rs | 1 + src/{cli/dashboard/scan => services}/adr.rs | 4 +- .../dashboard/scan => services}/discovery.rs | 27 +++--- .../dashboard/scan => services}/history.rs | 4 +- src/{cli/dashboard/scan => services}/mod.rs | 93 ++++++++++++++++--- .../dashboard/scan => services}/provenance.rs | 4 +- .../dashboard/scan => services}/references.rs | 0 .../dashboard/scan => services}/sidecar.rs | 2 +- .../dashboard/scan => services}/source.rs | 4 +- .../dashboard/scan => services}/target.rs | 6 +- tests/drift.rs | 12 +-- tests/prune.rs | 3 +- 16 files changed, 122 insertions(+), 56 deletions(-) rename src/{cli/dashboard/scan => services}/adr.rs (99%) rename src/{cli/dashboard/scan => services}/discovery.rs (92%) rename src/{cli/dashboard/scan => services}/history.rs (99%) rename src/{cli/dashboard/scan => services}/mod.rs (69%) rename src/{cli/dashboard/scan => services}/provenance.rs (98%) rename src/{cli/dashboard/scan => services}/references.rs (100%) rename src/{cli/dashboard/scan => services}/sidecar.rs (99%) rename src/{cli/dashboard/scan => services}/source.rs (99%) rename src/{cli/dashboard/scan => services}/target.rs (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af81fd..a40b0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- Dashboard scan logic moved from the dashboard binary into `commands::services`, shared by the axum dashboard and reusable by a future TUI with no behavioral change. - `forge install` and `forge deploy` default `--target` to `--source` when a `.forge` consumer manifest is present and `--target` is omitted. The consumer dir IS the place the user wants provider trees written; the previous behavior forced redundant `--target .` on every consumer-mode invocation. Module-root flows (no `.forge`) are unchanged: an omitted `--target` still resolves provider directories relative to the current working directory. (#52) ### Fixed diff --git a/src/assemble/tests.rs b/src/assemble/tests.rs index 25ed2dc..b6648dd 100644 --- a/src/assemble/tests.rs +++ b/src/assemble/tests.rs @@ -136,7 +136,7 @@ fn strip_frontmatter_block_value_of_dropped_key_does_not_leak() { #[test] fn map_field_finds_name_after_other_fields() { let content = "---\ndescription: test\nname: TestAgent\n---"; - let result = map_field(content, "name", |v| v.to_lowercase()); + let result = map_field(content, "name", str::to_lowercase); assert!(result.contains("name: testagent")); assert!(result.contains("description: test")); } @@ -144,7 +144,7 @@ fn map_field_finds_name_after_other_fields() { #[test] fn map_field_handles_double_quoted_value() { let content = "---\nname: \"SecurityArchitect\"\n---"; - let result = map_field(content, "name", |v| v.to_lowercase()); + let result = map_field(content, "name", str::to_lowercase); assert!( result.contains("name: securityarchitect"), "quoted value should be unwrapped before mapping: {result}" @@ -154,7 +154,7 @@ fn map_field_handles_double_quoted_value() { #[test] fn map_field_handles_single_quoted_value() { let content = "---\nname: 'SecurityArchitect'\n---"; - let result = map_field(content, "name", |v| v.to_lowercase()); + let result = map_field(content, "name", str::to_lowercase); assert!( result.contains("name: securityarchitect"), "single-quoted value should be unwrapped before mapping: {result}" @@ -164,7 +164,7 @@ fn map_field_handles_single_quoted_value() { #[test] fn map_field_returns_unchanged_when_field_missing() { let content = "---\ndescription: test\n---\nBody."; - let result = map_field(content, "name", |v| v.to_lowercase()); + let result = map_field(content, "name", str::to_lowercase); assert_eq!(result, content); } diff --git a/src/cli/dashboard/mod.rs b/src/cli/dashboard/mod.rs index adb94c6..664378e 100644 --- a/src/cli/dashboard/mod.rs +++ b/src/cli/dashboard/mod.rs @@ -1,10 +1,10 @@ mod assets; mod routes; -mod scan; mod server; mod templates; use commands::error::{Error, ErrorKind}; +use commands::services as scan; pub fn execute(root: &str, port: Option) -> Result { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/src/cli/dashboard/server.rs b/src/cli/dashboard/server.rs index 30cda10..417ec5e 100644 --- a/src/cli/dashboard/server.rs +++ b/src/cli/dashboard/server.rs @@ -6,7 +6,7 @@ use tokio::sync::RwLock; use super::routes::{self, DashboardState}; use super::scan; -use crate::cli::config; +use crate::cli::{config, watchlist}; pub async fn start(root: &Path, port: Option) -> Result<(), Error> { let root_owned = root.to_path_buf(); @@ -48,12 +48,13 @@ pub async fn start(root: &Path, port: Option) -> Result<(), Error> { pub fn build_state(root: &Path) -> Result { let provider_targets = load_provider_targets(root); - let view = scan::build_view(root, &provider_targets)?; + let watched_locations = watchlist::watched_locations(); + let view = scan::build_view(root, &provider_targets, &watched_locations)?; Ok(DashboardState { view, provider_targets, settings_filenames: config::load_settings_filenames(root), - local_repos: scan::discover_local_repos(root), + local_repos: scan::discover_local_repos(root, &watched_locations), version: env!("CARGO_PKG_VERSION").to_string(), binary_hash: compute_binary_hash(), scanned_at: chrono::Utc::now().format("%H:%M:%S").to_string(), diff --git a/src/commands.rs b/src/commands.rs index 2bdfdab..9d52b6f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,6 +8,7 @@ pub mod module; pub mod parse; pub mod provider; pub mod result; +pub mod services; pub mod target; pub mod view; pub mod yaml; diff --git a/src/cli/dashboard/scan/adr.rs b/src/services/adr.rs similarity index 99% rename from src/cli/dashboard/scan/adr.rs rename to src/services/adr.rs index fe9ec47..b949cbd 100644 --- a/src/cli/dashboard/scan/adr.rs +++ b/src/services/adr.rs @@ -5,8 +5,8 @@ use super::references::{artifact_staleness, truncate_summary}; use super::sidecar::{parse_adoption, recorded_subject_sha}; use super::source::{read_source_content, resolve_sidecar, strip_frontmatter}; use super::target::sidecar_name_warning; -use commands::manifest; -use commands::view::{Adr, ArtifactView}; +use crate::manifest; +use crate::view::{Adr, ArtifactView}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/cli/dashboard/scan/discovery.rs b/src/services/discovery.rs similarity index 92% rename from src/cli/dashboard/scan/discovery.rs rename to src/services/discovery.rs index 947ef63..1456597 100644 --- a/src/cli/dashboard/scan/discovery.rs +++ b/src/services/discovery.rs @@ -6,13 +6,17 @@ use super::source::{ parse_frontmatter, read_source_companions, resolve_sidecar, strip_frontmatter, }; use super::target::{git_log_in_repo, sidecar_name_warning}; -use commands::view::{ArtifactView, ModuleView}; +use crate::view::{ArtifactView, ModuleView}; use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -pub(super) fn discover_targets(root: &Path, providers: &[(String, String)]) -> Vec { +pub(super) fn discover_targets( + root: &Path, + providers: &[(String, String)], + watched_locations: &[PathBuf], +) -> Vec { let mut targets = Vec::new(); let home = dirs::home_dir(); if let Some(ref home_path) = home @@ -27,8 +31,8 @@ pub(super) fn discover_targets(root: &Path, providers: &[(String, String)]) -> V if !is_home && has_provider_dirs(&root_abs, providers) { targets.push(root_abs.clone()); } - for location in configured_locations() { - let canonical = fs::canonicalize(&location).unwrap_or(location); + for location in watched_locations { + let canonical = fs::canonicalize(location).unwrap_or_else(|_| location.clone()); if canonical != root_abs && !targets.contains(&canonical) && has_provider_dirs(&canonical, providers) @@ -39,17 +43,14 @@ pub(super) fn discover_targets(root: &Path, providers: &[(String, String)]) -> V targets } -/// Additional scan locations from the `forge watch` watchlist -/// (`~/.config/forge/watchlist.yaml`). -pub(super) fn configured_locations() -> Vec { - crate::cli::watchlist::watched_locations() -} - pub(super) fn has_provider_dirs(base: &Path, providers: &[(String, String)]) -> bool { providers.iter().any(|(_, dir)| base.join(dir).is_dir()) } -pub fn discover_local_repos(root: &Path) -> HashMap { +pub fn discover_local_repos( + root: &Path, + watched_locations: &[PathBuf], +) -> HashMap { let mut repos = HashMap::new(); let canonical = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); @@ -57,8 +58,8 @@ pub fn discover_local_repos(root: &Path) -> HashMap { if let Some(parent) = canonical.parent() { search_dirs.push(parent.to_path_buf()); } - for location in configured_locations() { - let loc = fs::canonicalize(&location).unwrap_or(location); + for location in watched_locations { + let loc = fs::canonicalize(location).unwrap_or_else(|_| location.clone()); register_repo(&loc, &mut repos); if let Some(parent) = loc.parent() { search_dirs.push(parent.to_path_buf()); diff --git a/src/cli/dashboard/scan/history.rs b/src/services/history.rs similarity index 99% rename from src/cli/dashboard/scan/history.rs rename to src/services/history.rs index c585312..35f5405 100644 --- a/src/cli/dashboard/scan/history.rs +++ b/src/services/history.rs @@ -2,8 +2,8 @@ use super::sidecar::{Sidecar, parse_adoption}; use super::source::resolve_sidecar; -use commands::manifest; -use commands::view::{Adoption, GitCommit}; +use crate::manifest; +use crate::view::{Adoption, GitCommit}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/cli/dashboard/scan/mod.rs b/src/services/mod.rs similarity index 69% rename from src/cli/dashboard/scan/mod.rs rename to src/services/mod.rs index 0a25f2d..ccfc4af 100644 --- a/src/cli/dashboard/scan/mod.rs +++ b/src/services/mod.rs @@ -14,23 +14,23 @@ mod sidecar; mod source; mod target; -pub(super) use adr::build_adr_artifact; -pub(super) use discovery::discover_local_repos; -pub(super) use history::{ +pub use adr::build_adr_artifact; +pub use discovery::discover_local_repos; +pub use history::{ extract_frontmatter_field, git_log_for_artifact, read_source_adoption, read_source_sidecar, source_at_deploy, }; +use crate::error::{Error, ErrorKind}; +use crate::provider::ContentKind; +use crate::view::{DashboardView, ModuleView, StatusSummary, Variant}; use adr::discover_adrs; -use commands::error::{Error, ErrorKind}; -use commands::provider::ContentKind; -use commands::view::{DashboardView, ModuleView, StatusSummary, Variant}; -use discovery::{configured_locations, discover_targets, scan_source_module}; +use discovery::{discover_targets, scan_source_module}; use provenance::collect_provenance; use references::artifact_staleness; use std::collections::{BTreeMap, HashSet}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use target::{PendingCompanion, attach_companions, scan_target}; /// Content kinds scanned, sourced from the shared `ContentKind` enum rather @@ -43,8 +43,12 @@ pub(super) fn content_kinds() -> [&'static str; 3] { ] } -pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result { - let targets = discover_targets(root, providers); +pub fn build_view( + root: &Path, + providers: &[(String, String)], + watched_locations: &[PathBuf], +) -> Result { + let targets = discover_targets(root, providers, watched_locations); if targets.is_empty() { return Err(Error::new( ErrorKind::Config, @@ -55,7 +59,7 @@ pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result = BTreeMap::new(); let mut summary = StatusSummary::default(); let mut pending_companions: Vec = Vec::new(); @@ -75,8 +79,8 @@ pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result = modules_by_source.into_values().collect(); - for location in configured_locations() { - let module_root = fs::canonicalize(&location).unwrap_or(location); + for location in watched_locations { + let module_root = fs::canonicalize(location).unwrap_or_else(|_| location.clone()); if let Some(mut watched_module) = scan_source_module(&module_root) { watched_module.is_target = true; modules.push(watched_module); @@ -217,3 +221,66 @@ pub(super) fn make_variant(repo: &Path, provider: &str, model: &str, file: &Path mode, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest; + use std::fs; + + #[test] + fn build_view_reads_deployed_skill_from_provider_manifest() { + let temp = tempfile::tempdir().unwrap(); + let provider_dir = temp.path().join(".claude"); + let skill_path = provider_dir.join("skills/Demo/SKILL.md"); + let sidecar_path = provider_dir.join("skills/Demo/.provenance/SKILL.yaml"); + let skill_content = "---\ndescription: Demo skill\n---\nUse the demo skill.\n"; + let fingerprint = manifest::content_sha256(skill_content); + + fs::create_dir_all(skill_path.parent().unwrap()).unwrap(); + fs::write(&skill_path, skill_content).unwrap(); + fs::create_dir_all(sidecar_path.parent().unwrap()).unwrap(); + fs::write( + &sidecar_path, + format!( + "source: https://github.com/example/forge-demo.git\n\ + subject:\n\ + - name: skills/Demo/SKILL.md\n\ + digest:\n\ + sha256: {fingerprint}\n\ + resolvedDependencies:\n\ + - uri: skills/Demo/SKILL.md\n\ + digest:\n\ + sha256: {fingerprint}\n" + ), + ) + .unwrap(); + fs::write( + provider_dir.join(".manifest"), + format!( + "skills:\n Demo:\n SKILL.md:\n fingerprint: {fingerprint}\n provenance: skills/Demo/.provenance/SKILL.yaml\n" + ), + ) + .unwrap(); + + let providers = vec![("claude".to_string(), ".claude".to_string())]; + let view = build_view(temp.path(), &providers, &[]).unwrap(); + let module = view + .modules + .iter() + .find(|module| module.name == "forge-demo") + .unwrap(); + let artifact = module + .artifacts + .iter() + .find(|artifact| artifact.name == "Demo" && artifact.kind == "skills") + .unwrap(); + + assert_eq!(artifact.description, "Demo skill"); + assert_eq!(artifact.content_body, "Use the demo skill.\n"); + assert_eq!( + artifact.providers.get("claude").unwrap().status, + manifest::FileStatus::Unchanged + ); + } +} diff --git a/src/cli/dashboard/scan/provenance.rs b/src/services/provenance.rs similarity index 98% rename from src/cli/dashboard/scan/provenance.rs rename to src/services/provenance.rs index 019a123..297b00a 100644 --- a/src/cli/dashboard/scan/provenance.rs +++ b/src/services/provenance.rs @@ -2,8 +2,8 @@ use super::content_kinds; use super::history::recorded_input_sha; -use commands::manifest; -use commands::view::{ProvenanceArtifact, ProvenanceView}; +use crate::manifest; +use crate::view::{ProvenanceArtifact, ProvenanceView}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/cli/dashboard/scan/references.rs b/src/services/references.rs similarity index 100% rename from src/cli/dashboard/scan/references.rs rename to src/services/references.rs diff --git a/src/cli/dashboard/scan/sidecar.rs b/src/services/sidecar.rs similarity index 99% rename from src/cli/dashboard/scan/sidecar.rs rename to src/services/sidecar.rs index 039da59..493f5c5 100644 --- a/src/cli/dashboard/scan/sidecar.rs +++ b/src/services/sidecar.rs @@ -1,6 +1,6 @@ //! SLSA provenance sidecar serde model and direct parsing. -use commands::view::{Adoption, Dependency}; +use crate::view::{Adoption, Dependency}; #[derive(serde::Deserialize)] pub(super) struct Sidecar { diff --git a/src/cli/dashboard/scan/source.rs b/src/services/source.rs similarity index 99% rename from src/cli/dashboard/scan/source.rs rename to src/services/source.rs index 694af5a..245f99f 100644 --- a/src/cli/dashboard/scan/source.rs +++ b/src/services/source.rs @@ -2,8 +2,8 @@ //! resolution, and manifest/key parsing. use super::history::extract_frontmatter_field; -use commands::manifest::{self, ManifestEntry}; -use commands::view::Companion; +use crate::manifest::{self, ManifestEntry}; +use crate::view::Companion; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/cli/dashboard/scan/target.rs b/src/services/target.rs similarity index 98% rename from src/cli/dashboard/scan/target.rs rename to src/services/target.rs index 4dfb99e..7bd362b 100644 --- a/src/cli/dashboard/scan/target.rs +++ b/src/services/target.rs @@ -10,10 +10,8 @@ use super::source::{ load_manifest, parse_artifact_key, read_artifact_content, read_source_content, resolve_source, resolve_source_name, resolve_source_path, }; -use commands::manifest::{self, FileStatus, ManifestEntry}; -use commands::view::{ - ArtifactView, Companion, GitCommit, ModuleView, ProviderStatus, StatusSummary, -}; +use crate::manifest::{self, FileStatus, ManifestEntry}; +use crate::view::{ArtifactView, Companion, GitCommit, ModuleView, ProviderStatus, StatusSummary}; use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/tests/drift.rs b/tests/drift.rs index 03a4d8d..6ae8f7c 100644 --- a/tests/drift.rs +++ b/tests/drift.rs @@ -206,9 +206,7 @@ fn drift_both_frontmatter_and_body_difference() { write_file( upstream_directory.path(), "rules/Diverged.md", - &format!( - "---\nname: Diverged\ndescription: different description\nversion: 2.0\n---\n\nUpstream body content.\n" - ), + "---\nname: Diverged\ndescription: different description\nversion: 2.0\n---\n\nUpstream body content.\n", ); let output = forge() @@ -745,12 +743,12 @@ fn ignore_both_frontmatter_and_body() { write_file( module_directory.path(), "rules/TestRule.md", - &format!("---\nname: TestRule\nproject: local\n---\n\nLocal body.\n"), + "---\nname: TestRule\nproject: local\n---\n\nLocal body.\n", ); write_file( upstream_directory.path(), "rules/TestRule.md", - &format!("---\nname: TestRule\nproject: upstream\n---\n\nUpstream body.\n"), + "---\nname: TestRule\nproject: upstream\n---\n\nUpstream body.\n", ); let output = forge() @@ -782,12 +780,12 @@ fn ignore_body_on_both_drift_keeps_frontmatter() { write_file( module_directory.path(), "rules/TestRule.md", - &format!("---\nname: TestRule\nversion: 2.0\n---\n\nLocal body.\n"), + "---\nname: TestRule\nversion: 2.0\n---\n\nLocal body.\n", ); write_file( upstream_directory.path(), "rules/TestRule.md", - &format!("---\nname: TestRule\nversion: 1.0\n---\n\nUpstream body.\n"), + "---\nname: TestRule\nversion: 1.0\n---\n\nUpstream body.\n", ); let output = forge() diff --git a/tests/prune.rs b/tests/prune.rs index a94ae9c..ff92173 100644 --- a/tests/prune.rs +++ b/tests/prune.rs @@ -237,8 +237,7 @@ fn run_two_pass_prune_for_provider(provider: &str) { assert_eq!( trash_entries.len(), 1, - "{provider}: exactly one timestamped trash entry expected, found {:?}", - trash_entries + "{provider}: exactly one timestamped trash entry expected, found {trash_entries:?}", ); let trash_dir = &trash_entries[0]; assert!(