mod common;
use clap::Parser;
use common::drive;
use face_cli::{Io, cli::Cli, run};
use std::io::Cursor;
fn drive_capture_error(args: &[&str], stdin: &str) -> (i32, String, String) {
let cli = Cli::try_parse_from(args).expect("argv parse");
let mut io = Io::new(
Cursor::new(stdin.as_bytes().to_vec()),
Vec::<u8>::new(),
Vec::<u8>::new(),
);
let outcome = run(cli, &mut io);
let stdout = String::from_utf8(io.stdout).expect("stdout utf-8");
let mut stderr = String::from_utf8(io.stderr).expect("stderr utf-8");
let exit_code = match outcome {
Ok(()) => 0,
Err(err) => {
stderr.push_str(&format!("face: {err}\n"));
1
}
};
(exit_code, stdout, stderr)
}
mod locator_axis {
use super::drive;
#[test]
fn rust_style_locator_clusters_by_module() {
let stdin = "[\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a1\",\"locator\":\"Room::Inbox::open\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a2\",\"locator\":\"Room::Inbox::close\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a3\",\"locator\":\"Room::Inbox::send\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a4\",\"locator\":\"Room::User::login\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a5\",\"locator\":\"Room::User::logout\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a6\",\"locator\":\"Room::Audio::start\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a7\",\"locator\":\"Room::Audio::stop\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a8\",\"locator\":\"Room::Audio::mute\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a9\",\"locator\":\"Room::Audio::join\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a10\",\"locator\":\"Room::Audio::leave\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b1\",\"locator\":\"WebView::Page::load\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b2\",\"locator\":\"WebView::Page::reload\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b3\",\"locator\":\"WebView::Page::stop\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b4\",\"locator\":\"WebView::Frame::resize\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b5\",\"locator\":\"WebView::Frame::move\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c1\",\"locator\":\"Wallet::Tx::send\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c2\",\"locator\":\"Wallet::Tx::sign\"}\
]";
let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
assert!(
stderr.contains("items by locator (auto, from json)"),
"verbose provenance names locator as the auto axis: stderr={stderr:?}",
);
let room = stdout.find("Room").expect("Room cluster line present");
let webview = stdout
.find("WebView")
.expect("WebView cluster line present");
let wallet = stdout.find("Wallet").expect("Wallet cluster line present");
assert!(
room < webview && webview < wallet,
"expected count-desc order Room > WebView > Wallet in stdout: {stdout:?}",
);
}
#[test]
fn low_cardinality_kind_beats_locator() {
let stdin = "[\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a1\",\"locator\":\"Room::Inbox::open\"},\
{\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"a2\",\"locator\":\"Room::Inbox::close\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a3\",\"locator\":\"Room::Inbox::send\"},\
{\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"a4\",\"locator\":\"Room::User::login\"},\
{\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"a5\",\"locator\":\"Room::User::logout\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a6\",\"locator\":\"Room::Audio::start\"},\
{\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"a7\",\"locator\":\"Room::Audio::stop\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"a8\",\"locator\":\"Room::Audio::mute\"},\
{\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"a9\",\"locator\":\"Room::Audio::join\"},\
{\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"a10\",\"locator\":\"Room::Audio::leave\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b1\",\"locator\":\"WebView::Page::load\"},\
{\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"b2\",\"locator\":\"WebView::Page::reload\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"b3\",\"locator\":\"WebView::Page::stop\"},\
{\"name\":\"Sym\",\"kind\":\"enum\",\"id\":\"b4\",\"locator\":\"WebView::Frame::resize\"},\
{\"name\":\"Sym\",\"kind\":\"trait\",\"id\":\"b5\",\"locator\":\"WebView::Frame::move\"},\
{\"name\":\"Sym\",\"kind\":\"function\",\"id\":\"c1\",\"locator\":\"Wallet::Tx::send\"},\
{\"name\":\"Sym\",\"kind\":\"struct\",\"id\":\"c2\",\"locator\":\"Wallet::Tx::sign\"}\
]";
let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
assert!(
stderr.contains("items by kind (auto, from json)"),
"canonical `kind` should beat the path-like `locator` fallback: stderr={stderr:?}",
);
assert!(
stdout.contains("function"),
"stdout has the `function` enum cluster: {stdout:?}",
);
}
}
mod ambiguous_error {
use super::{drive, drive_capture_error};
#[test]
fn lists_string_and_numeric_candidates_with_hint() {
let stdin = "{\"modules\":[\
{\"name\":\"core\",\"symbol_count\":42,\"edge_count\":12,\"coupling_ratio\":0.3},\
{\"name\":\"cli\",\"symbol_count\":30,\"edge_count\":8,\"coupling_ratio\":0.27},\
{\"name\":\"util\",\"symbol_count\":15,\"edge_count\":3,\"coupling_ratio\":0.2},\
{\"name\":\"web\",\"symbol_count\":50,\"edge_count\":20,\"coupling_ratio\":0.4},\
{\"name\":\"db\",\"symbol_count\":35,\"edge_count\":11,\"coupling_ratio\":0.31},\
{\"name\":\"auth\",\"symbol_count\":18,\"edge_count\":5,\"coupling_ratio\":0.28},\
{\"name\":\"net\",\"symbol_count\":22,\"edge_count\":7,\"coupling_ratio\":0.32},\
{\"name\":\"render\",\"symbol_count\":40,\"edge_count\":15,\"coupling_ratio\":0.38},\
{\"name\":\"audio\",\"symbol_count\":12,\"edge_count\":2,\"coupling_ratio\":0.17},\
{\"name\":\"video\",\"symbol_count\":28,\"edge_count\":9,\"coupling_ratio\":0.32}\
]}";
let (code, _stdout, stderr) = drive_capture_error(&["face"], stdin);
assert_ne!(code, 0, "ambiguous detection is fatal: stderr={stderr:?}");
assert!(
stderr.contains("cannot auto-pick a grouping field"),
"stderr names the §11.2 condition: {stderr:?}",
);
assert!(
stderr.contains("string fields:"),
"stderr has string-fields section: {stderr:?}",
);
assert!(
stderr.contains("name (10 distinct)"),
"stderr lists `name` with its cardinality: {stderr:?}",
);
assert!(
stderr.contains("numeric fields:"),
"stderr has numeric-fields section: {stderr:?}",
);
for field in ["symbol_count", "edge_count", "coupling_ratio"] {
assert!(
stderr.contains(field),
"stderr lists numeric field `{field}`: {stderr:?}",
);
}
assert!(stderr.contains("hint:"), "stderr has hint line: {stderr:?}",);
assert!(
stderr.contains("--score=FIELD"),
"hint suggests --score=FIELD: {stderr:?}",
);
assert!(
stderr.contains("--by=FIELD"),
"hint suggests --by=FIELD: {stderr:?}",
);
}
#[test]
fn score_hint_follow_through_succeeds() {
let stdin = "{\"modules\":[\
{\"name\":\"core\",\"symbol_count\":42,\"edge_count\":12,\"coupling_ratio\":0.3},\
{\"name\":\"cli\",\"symbol_count\":30,\"edge_count\":8,\"coupling_ratio\":0.27},\
{\"name\":\"util\",\"symbol_count\":15,\"edge_count\":3,\"coupling_ratio\":0.2},\
{\"name\":\"web\",\"symbol_count\":50,\"edge_count\":20,\"coupling_ratio\":0.4},\
{\"name\":\"db\",\"symbol_count\":35,\"edge_count\":11,\"coupling_ratio\":0.31},\
{\"name\":\"auth\",\"symbol_count\":18,\"edge_count\":5,\"coupling_ratio\":0.28},\
{\"name\":\"net\",\"symbol_count\":22,\"edge_count\":7,\"coupling_ratio\":0.32},\
{\"name\":\"render\",\"symbol_count\":40,\"edge_count\":15,\"coupling_ratio\":0.38},\
{\"name\":\"audio\",\"symbol_count\":12,\"edge_count\":2,\"coupling_ratio\":0.17},\
{\"name\":\"video\",\"symbol_count\":28,\"edge_count\":9,\"coupling_ratio\":0.32}\
]}";
let (code, stdout, stderr) = drive(&["face", "--score=symbol_count", "--verbose"], stdin);
assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
assert!(
stderr.contains("items by symbol_count (auto, from json)"),
"provenance names the score field as the axis: stderr={stderr:?}",
);
}
}
mod path_like_separators {
use super::drive;
#[test]
fn dotted_namespace_uses_prefix_at_depth_two() {
let stdin = "[\
{\"namespace\":\"com.example.foo.Bar\"},\
{\"namespace\":\"com.example.bar.Baz\"},\
{\"namespace\":\"com.example.qux.Qux\"},\
{\"namespace\":\"com.acme.alpha.Alpha\"},\
{\"namespace\":\"com.acme.beta.Beta\"},\
{\"namespace\":\"com.acme.gamma.Gamma\"}\
]";
let (code, stdout, stderr) = drive(&["face", "--verbose"], stdin);
assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
assert!(
stderr.contains("items by namespace (auto, from json)"),
"provenance names namespace as auto axis: stderr={stderr:?}",
);
assert!(
stdout.contains("com.acme") && stdout.contains("com.example"),
"expected 2-segment dotted prefix labels in stdout: {stdout:?}",
);
}
#[test]
fn file_extension_only_does_not_use_prefix() {
let stdin = "[\
{\"file\":\"Foo.swift\"},\
{\"file\":\"Foo.swift\"},\
{\"file\":\"Bar.swift\"},\
{\"file\":\"Bar.swift\"},\
{\"file\":\"Baz.kt\"},\
{\"file\":\"Baz.kt\"}\
]";
let (code, stdout, stderr) = drive(&["face", "--format=json"], stdin);
assert_eq!(code, 0, "stderr={stderr:?} stdout={stdout:?}");
let v: serde_json::Value =
serde_json::from_str(&stdout).expect("stdout is parseable JSON envelope");
let strategy = v["result"]["axes"][0]["strategy"]
.as_str()
.expect("strategy on first axis");
assert!(
matches!(strategy, "exact" | "top"),
"single-dot filename data must not pick prefix: strategy={strategy:?}, envelope={v}",
);
let labels: Vec<&str> = v["clusters"]
.as_array()
.expect("clusters[] is array")
.iter()
.filter_map(|c| c["label"].as_str())
.collect();
assert!(
!labels.contains(&"(other)"),
"no `(other)` bucket from prefix splitting: labels={labels:?}",
);
for value in ["Foo.swift", "Bar.swift", "Baz.kt"] {
assert!(
labels.contains(&value),
"expected distinct cluster for `{value}`: labels={labels:?}",
);
}
}
}