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
14 changes: 13 additions & 1 deletion cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ pub struct ImpactArgs {
#[arg(long)]
pub file: Option<String>,

/// Impact staged files only
#[arg(long, conflicts_with_all = &["last", "since"])]
pub staged: bool,

/// Impact files changed in the last N commits
#[arg(long, conflicts_with_all = &["staged", "since"])]
pub last: Option<usize>,

/// Impact files changed since a git ref
#[arg(long, conflicts_with_all = &["staged", "last"])]
pub since: Option<String>,

/// Maximum traversal depth (0 = unlimited, default: 3)
#[arg(long, default_value = "3")]
pub depth: usize,
Expand All @@ -182,7 +194,7 @@ pub struct ImpactArgs {
pub allow: bool,

/// Files or directories to parse
#[arg(value_name = "FILE_OR_DIR", num_args = 1..)]
#[arg(value_name = "FILE_OR_DIR", num_args = 0..)]
pub files: Vec<String>,
}

Expand Down
143 changes: 128 additions & 15 deletions cli/src/impact.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;

use ast::lang::graphs::{EdgeType, NodeType};
use console::style;
use lsp::Language;
use serde::Serialize;
use shared::{Error, Result};

use super::args::ImpactArgs;
use super::git::{get_changed_files, get_repo_root, get_staged_changes, get_working_tree_changes};
use super::output::{write_json_success, JsonWarning, Output, OutputMode};
use super::progress::CliSpinner;
use super::utils::{
Expand Down Expand Up @@ -54,6 +57,8 @@ struct ImpactSummary {

#[derive(Serialize)]
struct ImpactData {
#[serde(skip_serializing_if = "Option::is_none")]
mode: Option<String>,
seeds: Vec<ImpactSeed>,
files: Vec<String>,
depth: usize,
Expand All @@ -67,9 +72,11 @@ pub async fn run(
show_progress: bool,
output_mode: OutputMode,
) -> Result<()> {
if args.name.is_none() && args.file.is_none() {
let git_mode = args.staged || args.last.is_some() || args.since.is_some();

if !git_mode && args.name.is_none() && args.file.is_none() {
return Err(Error::validation(
"at least one of --name or --file is required",
"at least one of --name, --file, --staged, --last, or --since is required",
));
}

Expand All @@ -85,12 +92,93 @@ pub async fn run(
})
.transpose()?;

let files = expand_dirs_for_parse(&args.files);
if files.is_empty() {
return Err(Error::validation(
"no parseable files found in the given paths",
));
}
let (files, git_changed_files, mode_description) = if git_mode {
let cwd = std::env::current_dir()
.map_err(|e| Error::internal(format!("Failed to get current directory: {}", e)))?;
let cwd_str = cwd.to_string_lossy().to_string();
let repo_root = get_repo_root(&cwd_str)?;

let (changed, mode_desc) = if args.staged {
(get_staged_changes(&repo_root)?, "staged changes".to_string())
} else if let Some(n) = args.last {
let old_rev = format!("HEAD~{}", n);
(
get_changed_files(&repo_root, &old_rev, "HEAD")?,
format!("last {} commit{}", n, if n == 1 { "" } else { "s" }),
)
} else if let Some(ref since_ref) = args.since {
(
get_changed_files(&repo_root, since_ref, "HEAD")?,
format!("since {}", since_ref),
)
} else {
(get_working_tree_changes(&repo_root)?, "working tree changes".to_string())
};

let abs_files: Vec<String> = changed
.iter()
.filter(|f| Language::from_path(f).is_some())
.map(|f| {
Path::new(&repo_root)
.join(f)
.to_string_lossy()
.to_string()
})
.collect();

if abs_files.is_empty() {
if !output_mode.is_json() {
out.writeln(format!(
"{}",
style(format!("No parseable files changed in {}", mode_desc)).yellow()
))?;
}
if output_mode.is_json() {
let data = ImpactData {
mode: Some(mode_desc),
seeds: Vec::new(),
files: Vec::new(),
depth: args.depth,
summary: ImpactSummary {
total: 0,
by_type: HashMap::new(),
},
affected: Vec::new(),
};
write_json_success(out, "impact", data, vec![
JsonWarning::new("no_changes", "No parseable files changed"),
])?;
}
return Ok(());
}

let changed_rel: HashSet<String> = changed.into_iter().collect();

// If the user also passed positional files/dirs, use those as the parse scope
// (wider graph) but still seed only from the git-changed files.
// Without positional args, parse only the changed files themselves.
let parse_scope = if !args.files.is_empty() {
let scope = expand_dirs_for_parse(&args.files);
if scope.is_empty() {
return Err(Error::validation(
"no parseable files found in the given paths",
));
}
scope
} else {
abs_files
};

(parse_scope, Some(changed_rel), Some(mode_desc))
} else {
let files = expand_dirs_for_parse(&args.files);
if files.is_empty() {
return Err(Error::validation(
"no parseable files found in the given paths",
));
}
(files, None, None)
};

let spinner = if show_progress {
Some(CliSpinner::new(&format!(
Expand Down Expand Up @@ -141,16 +229,26 @@ pub async fn run(
.file
.as_ref()
.map_or(true, |f| n.node_data.file.ends_with(f.as_str()));
name_ok && file_ok
let git_ok = git_changed_files.as_ref().map_or(true, |changed| {
changed.iter().any(|cf| n.node_data.file.ends_with(cf))
});
name_ok && file_ok && git_ok
})
.collect();

if seeds.is_empty() {
let label = match (&args.name, &args.file) {
(Some(name), Some(file)) => format!("'{}' in {}", name, file),
(Some(name), None) => format!("'{}'", name),
(None, Some(file)) => format!("nodes in {}", file),
_ => unreachable!(),
let label = if git_mode {
mode_description
.as_deref()
.unwrap_or("git changes")
.to_string()
} else {
match (&args.name, &args.file) {
(Some(name), Some(file)) => format!("'{}' in {}", name, file),
(Some(name), None) => format!("'{}'", name),
(None, Some(file)) => format!("nodes in {}", file),
_ => "specified criteria".to_string(),
}
};
return Err(Error::validation(format!(
"no matching nodes found for {}",
Expand Down Expand Up @@ -185,6 +283,7 @@ pub async fn run(

if output_mode.is_json() {
let data = ImpactData {
mode: mode_description.clone(),
seeds: seeds
.iter()
.map(|s| ImpactSeed {
Expand Down Expand Up @@ -214,16 +313,30 @@ pub async fn run(
return Ok(());
}

if let Some(ref mode) = mode_description {
out.writeln(format!(
"{} {} file(s) changed in {} ({} seed nodes)",
style("Found").bold().cyan(),
style(git_changed_files.as_ref().map_or(0, |f| f.len())).bold().green(),
style(mode).yellow(),
style(seeds.len()).bold().green(),
))?;
out.newline()?;
}

for seed in &seeds {
out.writeln(format!(
"Impact: {} {} [{}:{}]",
" {} {} {} [{}:{}]",
style("→").cyan().bold(),
style(seed.node_type.to_string()).bold().cyan(),
style(&seed.node_data.name).bold().white(),
style(rel_path_from_cwd(&seed.node_data.file)).dim(),
style(seed.node_data.start + 1).dim()
))?;
}

out.newline()?;

if all_affected.is_empty() {
out.writeln(format!(
" {}",
Expand Down
2 changes: 2 additions & 0 deletions cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ mod cli_changes_cmd;

#[path = "cli/overview_cmd.rs"]
mod cli_overview_cmd;
#[path = "cli/impact_cmd.rs"]
mod cli_impact_cmd;
129 changes: 129 additions & 0 deletions cli/tests/cli/impact_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
mod common;

use common::{fixture_path, run_stakgraph};
use serde_json::Value;

// ── validation errors ─────────────────────────────────────────────────────────

#[test]
fn impact_missing_name_and_file_fails() {
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["impact", &dir]);
assert_ne!(out.exit_code, 0);
assert!(
out.stderr.contains("--name") || out.stderr.contains("--file"),
"stderr: {}",
out.stderr
);
}

#[test]
fn impact_invalid_type_fails() {
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["impact", "--name", "cn", "--type", "NotAType", &dir]);
assert_ne!(out.exit_code, 0);
assert!(out.stderr.contains("Unknown node type"), "stderr: {}", out.stderr);
}

#[test]
fn impact_unknown_name_fails() {
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["impact", "--name", "definitely_nonexistent_fn", &dir]);
assert_ne!(out.exit_code, 0);
assert!(
out.stderr.contains("no matching nodes"),
"stderr: {}",
out.stderr
);
}

// ── human output ──────────────────────────────────────────────────────────────

#[test]
fn impact_smoke_name_nextjs() {
let dir = fixture_path("src/testing/nextjs");
let out = run_stakgraph(&["impact", "--name", "cn", &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
assert!(out.stdout.contains("cn"), "stdout: {}", out.stdout);
assert!(out.stdout.contains("affected"), "stdout: {}", out.stdout);
}

#[test]
fn impact_smoke_file_nextjs() {
let utils = fixture_path("src/testing/nextjs/lib/utils.ts");
let dir = fixture_path("src/testing/nextjs");
let out = run_stakgraph(&["impact", "--file", &utils, &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
assert!(out.stdout.contains("affected"), "stdout: {}", out.stdout);
}

#[test]
fn impact_name_and_file_nextjs() {
let utils = fixture_path("src/testing/nextjs/lib/utils.ts");
let dir = fixture_path("src/testing/nextjs");
let out = run_stakgraph(&["impact", "--name", "cn", "--file", &utils, &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
assert!(out.stdout.contains("cn"), "stdout: {}", out.stdout);
}

#[test]
fn impact_depth_zero_does_not_crash() {
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["impact", "--name", "cn", "--depth", "0", &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
}

#[test]
fn impact_no_dependents_exits_cleanly() {
// convertUSDToSats is defined in currency.ts but nothing in the fixture calls it
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["impact", "--name", "convertUSDToSats", &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
assert!(
out.stdout.contains("No upstream") || out.stdout.contains("0 "),
"stdout: {}",
out.stdout
);
}

// ── JSON output ───────────────────────────────────────────────────────────────

#[test]
fn impact_json_valid_envelope() {
let dir = fixture_path("src/testing/nextjs");
let out = run_stakgraph(&["--json", "impact", "--name", "cn", &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
let v: Value = serde_json::from_str(&out.stdout).expect("invalid JSON");
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "impact");
assert!(v["data"]["summary"]["total"].is_number());
assert!(v["data"]["seeds"].is_array());
assert!(v["data"]["affected"].is_array());
}

#[test]
fn impact_json_affected_has_edge_chain() {
let dir = fixture_path("src/testing/nextjs");
let out = run_stakgraph(&["--json", "impact", "--name", "cn", &dir]);
assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr);
let v: Value = serde_json::from_str(&out.stdout).expect("invalid JSON");
let affected = v["data"]["affected"].as_array().expect("affected not array");
if !affected.is_empty() {
let first = &affected[0];
assert!(first["node_type"].is_string());
assert!(first["name"].is_string());
assert!(first["file"].is_string());
assert!(first["depth"].is_number());
assert!(first["edge_chain"].is_array());
}
}

#[test]
fn impact_json_no_match_returns_error_envelope() {
let dir = fixture_path("src/testing/nextjs/lib");
let out = run_stakgraph(&["--json", "impact", "--name", "nonexistent_xyz", &dir]);
assert_ne!(out.exit_code, 0);
let v: Value = serde_json::from_str(&out.stdout).expect("invalid JSON");
assert_eq!(v["ok"], false);
assert!(v["error"]["message"].is_string());
}
Loading