Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/assemble/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,15 @@ 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"));
}

#[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}"
Expand All @@ -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}"
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/dashboard/mod.rs
Original file line number Diff line number Diff line change
@@ -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<u16>) -> Result<i32, Error> {
let runtime = tokio::runtime::Builder::new_current_thread()
Expand Down
7 changes: 4 additions & 3 deletions src/cli/dashboard/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>) -> Result<(), Error> {
let root_owned = root.to_path_buf();
Expand Down Expand Up @@ -48,12 +48,13 @@ pub async fn start(root: &Path, port: Option<u16>) -> Result<(), Error> {

pub fn build_state(root: &Path) -> Result<DashboardState, Error> {
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(),
Expand Down
1 change: 1 addition & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/dashboard/scan/adr.rs → src/services/adr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
27 changes: 14 additions & 13 deletions src/cli/dashboard/scan/discovery.rs → src/services/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
pub(super) fn discover_targets(
root: &Path,
providers: &[(String, String)],
watched_locations: &[PathBuf],
) -> Vec<PathBuf> {
let mut targets = Vec::new();
let home = dirs::home_dir();
if let Some(ref home_path) = home
Expand All @@ -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)
Expand All @@ -39,26 +43,23 @@ 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<PathBuf> {
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<String, PathBuf> {
pub fn discover_local_repos(
root: &Path,
watched_locations: &[PathBuf],
) -> HashMap<String, PathBuf> {
let mut repos = HashMap::new();
let canonical = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());

let mut search_dirs: Vec<PathBuf> = Vec::new();
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
93 changes: 80 additions & 13 deletions src/cli/dashboard/scan/mod.rs → src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,8 +43,12 @@ pub(super) fn content_kinds() -> [&'static str; 3] {
]
}

pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result<DashboardView, Error> {
let targets = discover_targets(root, providers);
pub fn build_view(
root: &Path,
providers: &[(String, String)],
watched_locations: &[PathBuf],
) -> Result<DashboardView, Error> {
let targets = discover_targets(root, providers, watched_locations);
if targets.is_empty() {
return Err(Error::new(
ErrorKind::Config,
Expand All @@ -55,7 +59,7 @@ pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result<Dashboa
));
}

let local_repos = discover_local_repos(root);
let local_repos = discover_local_repos(root, watched_locations);
let mut modules_by_source: BTreeMap<String, ModuleView> = BTreeMap::new();
let mut summary = StatusSummary::default();
let mut pending_companions: Vec<PendingCompanion> = Vec::new();
Expand All @@ -75,8 +79,8 @@ pub fn build_view(root: &Path, providers: &[(String, String)]) -> Result<Dashboa

let mut modules: Vec<ModuleView> = 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);
Expand Down Expand Up @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/dashboard/scan/source.rs → src/services/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
6 changes: 2 additions & 4 deletions src/cli/dashboard/scan/target.rs → src/services/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
12 changes: 5 additions & 7 deletions tests/drift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading