use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::text_width::display_width;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputFormat {
Table,
Json,
Mermaid,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"table" => Ok(Self::Table),
"json" => Ok(Self::Json),
"mermaid" => Ok(Self::Mermaid),
other => Err(format!(
"unknown format '{other}'; expected 'table', 'json', or 'mermaid'"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteInfo {
pub method: String,
pub path: String,
pub handler: String,
pub source: String,
pub middleware: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset_opt_out: Option<bool>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub resource_shape: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
pub struct RoutesOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub format: OutputFormat,
pub filter: Option<&'a str>,
pub methods: &'a [String],
pub user_only: bool,
}
pub const DUMP_ENV: &str = "AUTUMN_DUMP_ROUTES";
pub const ALL_DUMP_MODE_ENVS: &[&str] = &[
crate::build::BUILD_STATIC_ENV,
DUMP_ENV,
crate::openapi::DUMP_ENV,
crate::cache_audit::DUMP_ENV,
crate::data_flow::DUMP_ENV,
crate::agents::DUMP_ENV,
crate::graph::DUMP_ENV,
crate::jobs::DUMP_ENV,
];
pub fn clear_other_dump_modes(command: &mut Command, own: &str) {
for mode in ALL_DUMP_MODE_ENVS {
if *mode != own {
command.env_remove(mode);
}
}
}
pub fn run(opts: &RoutesOptions<'_>) {
eprintln!("\u{1F342} autumn routes\n");
compile_binary(opts.package, opts.bin);
let binary = find_binary(opts.package, opts.bin);
let mut command = Command::new(&binary);
command.env(DUMP_ENV, "1");
clear_other_dump_modes(&mut command, DUMP_ENV);
let output = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.output()
.unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while dumping routes",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut routes: Vec<RouteInfo> = serde_json::from_str(&stdout).unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to parse route listing JSON: {e}");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
});
routes = apply_filters(routes, opts.filter, opts.methods, opts.user_only);
sort_routes(&mut routes);
match &opts.format {
OutputFormat::Table => print_table(&routes),
OutputFormat::Json => print_json(&routes),
OutputFormat::Mermaid => print_mermaid(&routes),
}
}
pub fn apply_filters(
routes: Vec<RouteInfo>,
filter: Option<&str>,
methods: &[String],
user_only: bool,
) -> Vec<RouteInfo> {
routes
.into_iter()
.filter(|r| {
if filter.is_some_and(|prefix| !r.path.starts_with(prefix)) {
return false;
}
if !methods.is_empty() && !methods.iter().any(|m| m.eq_ignore_ascii_case(&r.method)) {
return false;
}
if user_only && r.source == "framework" {
return false;
}
true
})
.collect()
}
pub fn sort_routes(routes: &mut [RouteInfo]) {
routes.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
}
pub fn print_table(routes: &[RouteInfo]) {
if routes.is_empty() {
println!("No routes found.");
return;
}
let table = format_table(routes);
print!("{table}");
}
pub fn format_table(routes: &[RouteInfo]) -> String {
const HEADERS: [&str; 7] = [
"Method",
"Path",
"Handler",
"Version",
"Status",
"Source",
"Middleware",
];
let widths = compute_column_widths(routes, &HEADERS);
let mut out = String::new();
out.push_str(&format_row(
&HEADERS.map(std::borrow::ToOwned::to_owned),
&widths,
));
out.push('\n');
for (i, &w) in widths.iter().enumerate() {
if i > 0 {
out.push_str(" ");
}
out.push_str(&"-".repeat(w));
}
out.push('\n');
for route in routes {
let middleware = if route.middleware.is_empty() {
String::new()
} else {
route.middleware.join(", ")
};
let version = route.api_version.as_deref().unwrap_or("-");
let status = route.status.as_deref().unwrap_or("-");
let cells = [
route.method.clone(),
route.path.clone(),
route.handler.clone(),
version.to_string(),
status.to_string(),
route.source.clone(),
middleware,
];
out.push_str(&format_row(&cells, &widths));
out.push('\n');
}
out
}
fn compute_column_widths(routes: &[RouteInfo], headers: &[&str; 7]) -> [usize; 7] {
let mut widths = [0usize; 7];
for (i, h) in headers.iter().enumerate() {
widths[i] = display_width(h);
}
for route in routes {
let middleware = if route.middleware.is_empty() {
String::new()
} else {
route.middleware.join(", ")
};
let version = route.api_version.as_deref().unwrap_or("-");
let status = route.status.as_deref().unwrap_or("-");
let cols = [
display_width(&route.method),
display_width(&route.path),
display_width(&route.handler),
display_width(version),
display_width(status),
display_width(&route.source),
display_width(&middleware),
];
for (i, &w) in cols.iter().enumerate() {
if w > widths[i] {
widths[i] = w;
}
}
}
widths
}
fn format_row(cells: &[String; 7], widths: &[usize; 7]) -> String {
cells
.iter()
.zip(widths.iter())
.enumerate()
.map(|(i, (cell, &w))| {
if i == 0 {
format!("{cell:<w$}")
} else {
format!(" {cell:<w$}")
}
})
.collect::<String>()
.trim_end()
.to_owned()
}
pub fn print_json(routes: &[RouteInfo]) {
let json =
serde_json::to_string_pretty(routes).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
println!("{json}");
}
pub fn print_mermaid(routes: &[RouteInfo]) {
print!("{}", format_mermaid(routes));
}
fn mermaid_label(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'#' => out.push_str("#35;"),
'"' => out.push_str("#quot;"),
'&' => out.push_str("#amp;"),
'<' => out.push_str("#lt;"),
'>' => out.push_str("#gt;"),
_ => out.push(c),
}
}
out
}
pub fn format_mermaid(routes: &[RouteInfo]) -> String {
use std::fmt::Write as _;
let mut by_source: std::collections::BTreeMap<&str, Vec<&RouteInfo>> =
std::collections::BTreeMap::new();
for route in routes {
by_source.entry(&route.source).or_default().push(route);
}
let mut out = String::from("flowchart LR\n");
let mut node_id = 0_usize;
for (source_id, (source, source_routes)) in by_source.into_iter().enumerate() {
let _ = writeln!(
out,
" subgraph src{}[\"{}\"]",
source_id + 1,
mermaid_label(source)
);
for route in source_routes {
node_id += 1;
let _ = writeln!(
out,
" route{node_id}(\"<b>{}</b> {}\")",
mermaid_label(&route.method),
mermaid_label(&route.path)
);
}
out.push_str(" end\n");
}
out
}
pub fn find_binary(package: Option<&str>, bin: Option<&str>) -> PathBuf {
find_binary_in_profile(package, bin, &CargoProfile::default())
}
pub fn find_binary_in_profile(
package: Option<&str>,
bin: Option<&str>,
profile: &CargoProfile,
) -> PathBuf {
find_resolved_binary_in_profile(package, bin, profile).path
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedBinary {
pub path: PathBuf,
pub manifest_dir: Option<PathBuf>,
}
pub fn find_resolved_binary_in_profile(
package: Option<&str>,
bin: Option<&str>,
profile: &CargoProfile,
) -> ResolvedBinary {
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.output()
.expect("failed to run cargo metadata");
if !output.status.success() {
eprintln!("\u{2717} Failed to read cargo metadata");
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
std::process::exit(1);
}
let metadata: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("parse cargo metadata");
let cwd = std::env::current_dir().expect("current dir");
resolve_binary_target(&metadata, package, &cwd, bin, profile).map_or_else(
|error| {
eprintln!("\u{2717} {error}");
std::process::exit(1);
},
|resolved| ResolvedBinary {
path: crate::built_artifact::prefer_built(resolved.path),
..resolved
},
)
}
#[cfg(test)]
fn resolve_binary_in_profile(
metadata: &serde_json::Value,
package: Option<&str>,
cwd: &Path,
bin: Option<&str>,
profile: &CargoProfile,
) -> Result<PathBuf, String> {
resolve_binary_target(metadata, package, cwd, bin, profile).map(|resolved| resolved.path)
}
fn resolve_binary_target(
metadata: &serde_json::Value,
package: Option<&str>,
cwd: &Path,
bin: Option<&str>,
profile: &CargoProfile,
) -> Result<ResolvedBinary, String> {
let target_dir = metadata["target_directory"]
.as_str()
.ok_or("target_directory missing from cargo metadata")?;
let packages = metadata["packages"]
.as_array()
.ok_or("packages missing from cargo metadata")?;
let matching_packages: Vec<_> = package.map_or_else(
|| {
packages
.iter()
.filter(|pkg| {
pkg["manifest_path"]
.as_str()
.and_then(|manifest| Path::new(manifest).parent())
.is_some_and(|dir| cwd.starts_with(dir) || dir.starts_with(cwd))
})
.collect()
},
|pkg_name| {
packages
.iter()
.filter(|pkg| pkg["name"].as_str() == Some(pkg_name))
.collect()
},
);
let mut candidates: Vec<(String, String, Option<PathBuf>)> = Vec::new();
for pkg in &matching_packages {
let pkg_name = match pkg["name"].as_str() {
Some(n) => n.to_owned(),
None => continue,
};
let bins: Vec<String> = pkg["targets"]
.as_array()
.map(|targets| {
targets
.iter()
.filter_map(|t| {
let is_bin = t["kind"].as_array()?.iter().any(|k| k == "bin");
if is_bin {
t["name"].as_str().map(String::from)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
let chosen = if let Some(bin_name) = bin {
if bins.iter().any(|b| b == bin_name) {
Some(bin_name.to_owned())
} else if !bins.is_empty() {
return Err(format!(
"package '{pkg_name}' has no binary named '{bin_name}' \
(available: {})",
bins.join(", ")
));
} else {
None
}
} else if bins.len() > 1 {
return Err(format!(
"package '{pkg_name}' has multiple binary targets ({}); \
use --bin to select one",
bins.join(", ")
));
} else {
bins.into_iter().next()
};
if let Some(b) = chosen {
let manifest_dir = pkg["manifest_path"]
.as_str()
.and_then(|manifest| Path::new(manifest).parent())
.map(Path::to_path_buf);
candidates.push((pkg_name, b, manifest_dir));
}
}
if package.is_none() && candidates.len() > 1 {
let names: Vec<&str> = candidates.iter().map(|(n, _, _)| n.as_str()).collect();
return Err(format!(
"multiple binary packages found in workspace ({}); \
use -p / --package to select one",
names.join(", ")
));
}
let (_, bin_name, manifest_dir) = candidates.pop().ok_or_else(|| {
package.map_or_else(
|| "no binary target found in current package".to_owned(),
|pkg_name| format!("no binary target found in package '{pkg_name}'"),
)
})?;
let mut path = PathBuf::from(target_dir);
path.push(profile.artifact_dir());
path.push(bin_name);
if cfg!(windows) {
path.set_extension("exe");
}
Ok(ResolvedBinary { path, manifest_dir })
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CargoProfile {
pub release: bool,
pub profile: Option<String>,
}
impl CargoProfile {
#[must_use]
pub const fn from_release(release: bool) -> Self {
Self {
release,
profile: None,
}
}
#[must_use]
pub const fn is_default(&self) -> bool {
!self.release && self.profile.is_none()
}
#[must_use]
pub fn to_args(&self) -> Vec<String> {
self.profile.as_ref().map_or_else(
|| {
if self.release {
vec!["--release".to_string()]
} else {
Vec::new()
}
},
|name| vec!["--profile".to_string(), name.clone()],
)
}
#[must_use]
pub fn artifact_dir(&self) -> &str {
if self.release {
"release"
} else if let Some(name) = &self.profile {
match name.as_str() {
"dev" | "test" => "debug",
"release" | "bench" => "release",
other => other,
}
} else {
"debug"
}
}
}
pub fn compile_binary(package: Option<&str>, bin: Option<&str>) {
compile_binary_with(
package,
bin,
&CargoFeatures::default(),
&CargoProfile::default(),
);
}
pub fn compile_binary_with(
package: Option<&str>,
bin: Option<&str>,
features: &CargoFeatures,
profile: &CargoProfile,
) {
let mut cargo = Command::new("cargo");
cargo.arg("build");
if let Some(pkg) = package {
cargo.args(["-p", pkg]);
}
if let Some(b) = bin {
cargo.args(["--bin", b]);
}
cargo.args(features.to_args());
cargo.args(profile.to_args());
let status = crate::built_artifact::run_build(&mut cargo).expect("failed to run cargo build");
if !status.success() {
eprintln!("\u{2717} Compilation failed");
std::process::exit(1);
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CargoFeatures {
pub features: Vec<String>,
pub all: bool,
pub no_default: bool,
}
impl CargoFeatures {
#[must_use]
pub const fn is_default(&self) -> bool {
self.features.is_empty() && !self.all && !self.no_default
}
#[must_use]
pub fn to_args(&self) -> Vec<String> {
let mut args = Vec::new();
if self.no_default {
args.push("--no-default-features".to_string());
}
if self.all {
args.push("--all-features".to_string());
}
for f in &self.features {
args.push("--features".to_string());
args.push(f.clone());
}
args
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_dump_mode_envs_has_no_duplicate_entries() {
let mut seen = std::collections::HashSet::new();
for mode in ALL_DUMP_MODE_ENVS {
assert!(seen.insert(*mode), "{mode} appears more than once");
}
}
#[test]
fn clear_other_dump_modes_preserves_own_and_clears_every_other_entry() {
for &own in ALL_DUMP_MODE_ENVS {
let mut command = Command::new("true");
for mode in ALL_DUMP_MODE_ENVS {
command.env(mode, "1");
}
clear_other_dump_modes(&mut command, own);
for &mode in ALL_DUMP_MODE_ENVS {
let value = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new(mode));
if mode == own {
assert_eq!(
value,
Some((std::ffi::OsStr::new(mode), Some(std::ffi::OsStr::new("1")))),
"own mode {mode} must survive clearing: {value:?}"
);
} else {
assert_eq!(
value,
Some((std::ffi::OsStr::new(mode), None)),
"{mode} must be cleared when own is {own}: {value:?}"
);
}
}
}
}
fn make_route(method: &str, path: &str, source: &str) -> RouteInfo {
RouteInfo {
method: method.to_owned(),
path: path.to_owned(),
handler: format!("{}_handler", path.trim_start_matches('/').replace('/', "_")),
source: source.to_owned(),
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
resource_shape: String::new(),
pools: Vec::new(),
group: None,
}
}
fn sample_routes() -> Vec<RouteInfo> {
vec![
make_route("GET", "/posts", "user"),
make_route("POST", "/posts", "user"),
make_route("GET", "/posts/{id}", "user"),
make_route("GET", "/actuator/health", "framework"),
make_route("GET", "/about", "user"),
make_route("GET", "/api/posts", "plugin:harvest"),
]
}
#[test]
fn parse_format_table() {
let f: OutputFormat = "table".parse().unwrap();
assert_eq!(f, OutputFormat::Table);
}
#[test]
fn parse_format_json() {
let f: OutputFormat = "json".parse().unwrap();
assert_eq!(f, OutputFormat::Json);
}
#[test]
fn parse_format_case_insensitive() {
let f: OutputFormat = "JSON".parse().unwrap();
assert_eq!(f, OutputFormat::Json);
let f: OutputFormat = "Table".parse().unwrap();
assert_eq!(f, OutputFormat::Table);
}
#[test]
fn parse_format_mermaid() {
let f: OutputFormat = "mermaid".parse().unwrap();
assert_eq!(f, OutputFormat::Mermaid);
}
#[test]
fn parse_format_unknown_is_error() {
let result: Result<OutputFormat, _> = "xml".parse();
assert!(result.is_err());
}
#[test]
fn filter_no_constraints_returns_all() {
let routes = sample_routes();
let count = routes.len();
let result = apply_filters(routes, None, &[], false);
assert_eq!(result.len(), count);
}
#[test]
fn filter_by_path_prefix() {
let routes = sample_routes();
let result = apply_filters(routes, Some("/posts"), &[], false);
assert!(result.iter().all(|r| r.path.starts_with("/posts")));
assert_eq!(result.len(), 3);
}
#[test]
fn filter_path_prefix_exact_match() {
let routes = sample_routes();
let result = apply_filters(routes, Some("/about"), &[], false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "/about");
}
#[test]
fn filter_by_method_get() {
let routes = sample_routes();
let methods = vec!["GET".to_owned()];
let result = apply_filters(routes, None, &methods, false);
assert!(result.iter().all(|r| r.method == "GET"));
}
#[test]
fn filter_by_method_post() {
let routes = sample_routes();
let methods = vec!["POST".to_owned()];
let result = apply_filters(routes, None, &methods, false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].method, "POST");
}
#[test]
fn filter_by_multiple_methods() {
let routes = sample_routes();
let methods = vec!["GET".to_owned(), "POST".to_owned()];
let result = apply_filters(routes, None, &methods, false);
assert!(
result
.iter()
.all(|r| r.method == "GET" || r.method == "POST")
);
}
#[test]
fn filter_method_case_insensitive() {
let routes = sample_routes();
let methods = vec!["get".to_owned()];
let result = apply_filters(routes, None, &methods, false);
assert!(!result.is_empty());
assert!(result.iter().all(|r| r.method == "GET"));
}
#[test]
fn filter_user_only_excludes_framework() {
let routes = sample_routes();
let result = apply_filters(routes, None, &[], true);
assert!(result.iter().all(|r| r.source != "framework"));
}
#[test]
fn filter_user_only_keeps_plugin_routes() {
let routes = sample_routes();
let result = apply_filters(routes, None, &[], true);
assert!(
result.iter().any(|r| r.source.starts_with("plugin:")),
"plugin routes should be kept with --user-only"
);
}
#[test]
fn filter_combines_path_and_method() {
let routes = sample_routes();
let methods = vec!["GET".to_owned()];
let result = apply_filters(routes, Some("/posts"), &methods, false);
assert!(
result
.iter()
.all(|r| r.path.starts_with("/posts") && r.method == "GET")
);
assert_eq!(result.len(), 2);
}
#[test]
fn sort_routes_by_path_then_method() {
let mut routes = vec![
make_route("POST", "/posts", "user"),
make_route("GET", "/about", "user"),
make_route("GET", "/posts", "user"),
];
sort_routes(&mut routes);
assert_eq!(routes[0].path, "/about");
assert_eq!(routes[1].path, "/posts");
assert_eq!(routes[1].method, "GET");
assert_eq!(routes[2].path, "/posts");
assert_eq!(routes[2].method, "POST");
}
#[test]
fn format_table_contains_headers() {
let routes = vec![make_route("GET", "/posts", "user")];
let table = format_table(&routes);
assert!(table.contains("Method"), "missing Method header");
assert!(table.contains("Path"), "missing Path header");
assert!(table.contains("Handler"), "missing Handler header");
assert!(table.contains("Source"), "missing Source header");
assert!(table.contains("Middleware"), "missing Middleware header");
}
#[test]
fn format_table_contains_route_data() {
let routes = vec![make_route("GET", "/posts", "user")];
let table = format_table(&routes);
assert!(table.contains("GET"), "missing method");
assert!(table.contains("/posts"), "missing path");
assert!(table.contains("user"), "missing source");
}
#[test]
fn format_table_has_separator_line() {
let routes = vec![make_route("GET", "/", "user")];
let table = format_table(&routes);
assert!(table.contains("---"), "missing separator line");
}
#[test]
fn format_table_empty_routes_still_has_headers() {
let routes: Vec<RouteInfo> = vec![];
let table = format_table(&routes);
assert!(table.contains("Method"));
}
#[test]
fn format_table_middleware_shown_when_present() {
let route = RouteInfo {
method: "GET".to_owned(),
path: "/admin".to_owned(),
handler: "admin".to_owned(),
source: "user".to_owned(),
middleware: vec!["secured".to_owned(), "cached(60s)".to_owned()],
api_version: None,
status: None,
sunset_opt_out: None,
resource_shape: String::new(),
pools: Vec::new(),
group: None,
};
let table = format_table(&[route]);
assert!(table.contains("secured"), "missing middleware label");
assert!(table.contains("cached(60s)"), "missing middleware label");
}
#[test]
fn print_json_produces_valid_json() {
let routes = sample_routes();
let json_str = serde_json::to_string_pretty(&routes).unwrap();
let parsed: Vec<RouteInfo> = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed.len(), routes.len());
}
#[test]
fn json_round_trip_keeps_route_group() {
let dump = r#"[
{"method": "GET", "path": "/landing", "handler": "landing", "source": "user",
"middleware": [], "group": "marketing"},
{"method": "GET", "path": "/live", "handler": "live", "source": "framework",
"middleware": []}
]"#;
let routes: Vec<RouteInfo> = serde_json::from_str(dump).expect("dump parses");
assert_eq!(routes[0].group.as_deref(), Some("marketing"));
assert_eq!(routes[1].group, None);
let json = serde_json::to_string_pretty(&routes).expect("serializes");
assert!(json.contains("\"group\": \"marketing\""), "{json}");
assert_eq!(json.matches("\"group\"").count(), 1, "{json}");
}
#[test]
fn format_mermaid_contains_expected_nodes() {
let routes = sample_routes();
let mermaid = format_mermaid(&routes);
assert!(mermaid.starts_with("flowchart LR"));
assert!(mermaid.contains("subgraph src1[\"framework\"]"));
assert!(mermaid.contains("subgraph src2[\"plugin:harvest\"]"));
assert!(mermaid.contains("subgraph src3[\"user\"]"));
assert!(mermaid.contains("\"<b>GET</b> /about\""));
assert!(mermaid.contains("\"<b>GET</b> /api/posts\""));
assert!(mermaid.contains("\"<b>GET</b> /actuator/health\""));
assert!(mermaid.contains("\"<b>POST</b> /posts\""));
assert!(mermaid.contains("\"<b>GET</b> /posts/{id}\""));
}
#[test]
fn mermaid_label_encodes_every_metacharacter() {
assert_eq!(
mermaid_label(r#"sales <beta> & "x" #quot;"#),
"sales #lt;beta#gt; #amp; #quot;x#quot; #35;quot;"
);
assert_eq!(mermaid_label("/posts/{id}"), "/posts/{id}");
}
#[test]
fn format_mermaid_empty_is_valid_flowchart() {
assert_eq!(format_mermaid(&[]), "flowchart LR\n");
}
#[test]
fn format_mermaid_quotes_unsafe_sources_and_keeps_them_distinct() {
let mut routes = sample_routes();
let base = routes[0].clone();
routes.push(RouteInfo {
source: "plugin:foo-bar".to_owned(),
..base.clone()
});
routes.push(RouteInfo {
source: "plugin:foo_bar".to_owned(),
..base.clone()
});
routes.push(RouteInfo {
source: "plugin:react_graphql::GraphqlPlugin@/graphql \"x\"".to_owned(),
..base
});
let mermaid = format_mermaid(&routes);
assert!(mermaid.contains("[\"plugin:foo-bar\"]"));
assert!(mermaid.contains("[\"plugin:foo_bar\"]"));
assert!(
mermaid.contains("[\"plugin:react_graphql::GraphqlPlugin@/graphql #quot;x#quot;\"]")
);
let subgraphs = mermaid
.lines()
.filter(|l| l.trim_start().starts_with("subgraph "))
.count();
assert_eq!(subgraphs, 6);
}
#[test]
fn resolve_binary_honors_the_requested_profile() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{
"name": "hello",
"kind": ["bin"],
"src_path": "/projects/hello/src/main.rs"
}]
}]
});
let cwd = Path::new("/projects/hello");
let debug = resolve_binary_in_profile(&metadata, None, cwd, None, &CargoProfile::default())
.expect("the debug binary resolves");
let release = resolve_binary_in_profile(
&metadata,
None,
cwd,
None,
&CargoProfile::from_release(true),
)
.expect("the release binary resolves");
assert!(
debug.starts_with("/tmp/target/debug"),
"debug must resolve under target/debug: {}",
debug.display()
);
assert!(
release.starts_with("/tmp/target/release"),
"release must resolve under target/release: {}",
release.display()
);
assert_eq!(
debug.file_name(),
release.file_name(),
"only the profile directory differs"
);
}
#[test]
fn resolve_binary_by_package_name() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{
"name": "hello",
"kind": ["bin"],
"src_path": "/projects/hello/src/main.rs"
}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
Some("hello"),
Path::new("/projects"),
None,
&CargoProfile::default(),
);
let expected = if cfg!(windows) {
PathBuf::from("/tmp/target/debug/hello.exe")
} else {
PathBuf::from("/tmp/target/debug/hello")
};
assert_eq!(result.unwrap(), expected);
}
#[test]
fn resolve_binary_by_cwd() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{
"name": "hello",
"kind": ["bin"],
"src_path": "/projects/hello/src/main.rs"
}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/projects/hello"),
None,
&CargoProfile::default(),
);
let expected = if cfg!(windows) {
PathBuf::from("/tmp/target/debug/hello.exe")
} else {
PathBuf::from("/tmp/target/debug/hello")
};
assert_eq!(result.unwrap(), expected);
}
#[test]
fn resolve_binary_reports_missing_package() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{"name": "hello", "kind": ["bin"]}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
Some("missing"),
Path::new("/projects"),
None,
&CargoProfile::default(),
);
assert!(result.unwrap_err().contains("package 'missing'"));
}
#[test]
fn resolve_binary_errors_on_multiple_workspace_candidates() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [
{
"name": "alpha",
"manifest_path": "/ws/alpha/Cargo.toml",
"targets": [{"name": "alpha", "kind": ["bin"]}]
},
{
"name": "beta",
"manifest_path": "/ws/beta/Cargo.toml",
"targets": [{"name": "beta", "kind": ["bin"]}]
}
]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws"),
None,
&CargoProfile::default(),
);
let err = result.unwrap_err();
assert!(
err.contains("multiple binary packages"),
"expected ambiguity error, got: {err}"
);
assert!(
err.contains("-p") || err.contains("--package"),
"should hint at -p flag"
);
}
#[test]
fn resolve_binary_cwd_with_single_match_succeeds() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [
{
"name": "alpha",
"manifest_path": "/ws/alpha/Cargo.toml",
"targets": [{"name": "alpha", "kind": ["bin"]}]
},
{
"name": "beta",
"manifest_path": "/ws/beta/Cargo.toml",
"targets": [{"name": "beta", "kind": ["bin"]}]
}
]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws/alpha"),
None,
&CargoProfile::default(),
)
.unwrap();
assert!(result.to_string_lossy().contains("alpha"));
}
#[test]
fn resolve_binary_lib_only_package_is_skipped() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [
{
"name": "mylib",
"manifest_path": "/ws/mylib/Cargo.toml",
"targets": [{"name": "mylib", "kind": ["lib"]}]
},
{
"name": "myapp",
"manifest_path": "/ws/myapp/Cargo.toml",
"targets": [{"name": "myapp", "kind": ["bin"]}]
}
]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws"),
None,
&CargoProfile::default(),
)
.unwrap();
assert!(result.to_string_lossy().contains("myapp"));
}
#[test]
fn resolve_binary_no_binary_target_errors() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "mylib",
"manifest_path": "/ws/mylib/Cargo.toml",
"targets": [{"name": "mylib", "kind": ["lib"]}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws/mylib"),
None,
&CargoProfile::default(),
);
assert!(result.unwrap_err().contains("no binary target"));
}
#[test]
fn resolve_binary_from_subdirectory_finds_parent_package() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{"name": "hello", "kind": ["bin"]}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/projects/hello/src"),
None,
&CargoProfile::default(),
);
assert!(
result.unwrap().to_string_lossy().contains("hello"),
"should resolve binary from subdirectory"
);
}
#[test]
fn resolve_binary_errors_on_multi_bin_package() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "myapp",
"manifest_path": "/ws/myapp/Cargo.toml",
"targets": [
{"name": "server", "kind": ["bin"]},
{"name": "migrate", "kind": ["bin"]}
]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws/myapp"),
None,
&CargoProfile::default(),
);
let err = result.unwrap_err();
assert!(
err.contains("multiple binary targets"),
"expected multi-bin error, got: {err}"
);
assert!(
err.contains("--bin"),
"should hint at --bin flag, got: {err}"
);
}
#[test]
fn resolve_binary_bin_flag_selects_named_target() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "myapp",
"manifest_path": "/ws/myapp/Cargo.toml",
"targets": [
{"name": "server", "kind": ["bin"]},
{"name": "migrate", "kind": ["bin"]}
]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws/myapp"),
Some("migrate"),
&CargoProfile::default(),
)
.unwrap();
assert!(
result.to_string_lossy().contains("migrate"),
"should resolve to the named binary"
);
}
#[test]
fn resolve_binary_bin_flag_wrong_name_errors() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "myapp",
"manifest_path": "/ws/myapp/Cargo.toml",
"targets": [{"name": "server", "kind": ["bin"]}]
}]
});
let result = resolve_binary_in_profile(
&metadata,
None,
Path::new("/ws/myapp"),
Some("missing"),
&CargoProfile::default(),
);
let err = result.unwrap_err();
assert!(
err.contains("no binary named 'missing'"),
"expected named-binary error, got: {err}"
);
assert!(
err.contains("server"),
"should list available binaries, got: {err}"
);
}
#[test]
fn a_default_feature_selection_adds_no_cargo_flags() {
let f = CargoFeatures::default();
assert!(f.is_default());
assert!(f.to_args().is_empty());
}
#[test]
fn a_feature_selection_forwards_every_flag_cargo_needs() {
let f = CargoFeatures {
features: vec!["db,cache-moka".to_string(), "redis".to_string()],
all: false,
no_default: true,
};
assert!(!f.is_default());
assert_eq!(
f.to_args(),
vec![
"--no-default-features",
"--features",
"db,cache-moka",
"--features",
"redis",
]
);
let all = CargoFeatures {
all: true,
..CargoFeatures::default()
};
assert!(!all.is_default());
assert_eq!(all.to_args(), vec!["--all-features"]);
}
fn profile_metadata() -> serde_json::Value {
serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": [{
"name": "hello",
"kind": ["bin"],
"src_path": "/projects/hello/src/main.rs"
}]
}]
})
}
fn resolve_profile_dir(profile: &CargoProfile) -> String {
let metadata = profile_metadata();
let path =
resolve_binary_in_profile(&metadata, None, Path::new("/projects/hello"), None, profile)
.expect("the binary resolves");
path.parent()
.expect("binary has a parent dir")
.file_name()
.expect("dir has a name")
.to_string_lossy()
.into_owned()
}
#[test]
fn a_default_profile_selection_adds_no_cargo_flags_and_resolves_debug() {
let p = CargoProfile::default();
assert!(p.is_default());
assert!(p.to_args().is_empty());
assert_eq!(resolve_profile_dir(&p), "debug");
}
#[test]
fn a_release_selection_forwards_the_flag_and_resolves_release() {
let p = CargoProfile::from_release(true);
assert!(!p.is_default());
assert_eq!(p.to_args(), vec!["--release"]);
assert_eq!(p.artifact_dir(), "release");
assert_eq!(resolve_profile_dir(&p), "release");
}
#[test]
fn a_named_profile_forwards_the_flag_and_resolves_the_named_dir() {
let p = CargoProfile {
release: false,
profile: Some("ci".to_string()),
};
assert!(!p.is_default());
assert_eq!(p.to_args(), vec!["--profile", "ci"]);
assert_eq!(p.artifact_dir(), "ci");
assert_eq!(resolve_profile_dir(&p), "ci");
}
#[test]
fn a_dev_profile_resolves_to_the_debug_dir() {
let p = CargoProfile {
release: false,
profile: Some("dev".to_string()),
};
assert!(!p.is_default());
assert_eq!(p.to_args(), vec!["--profile", "dev"]);
assert_eq!(p.artifact_dir(), "debug");
assert_eq!(resolve_profile_dir(&p), "debug");
}
#[test]
fn the_test_and_bench_profiles_resolve_to_their_inherited_dirs() {
for (name, dir) in [("test", "debug"), ("bench", "release")] {
let p = CargoProfile {
release: false,
profile: Some(name.to_string()),
};
assert_eq!(p.to_args(), vec!["--profile", name]);
assert_eq!(p.artifact_dir(), dir, "{name}");
assert_eq!(resolve_profile_dir(&p), dir, "{name}");
}
}
#[test]
fn a_release_profile_name_resolves_to_the_release_dir() {
let p = CargoProfile {
release: false,
profile: Some("release".to_string()),
};
assert_eq!(p.artifact_dir(), "release");
assert_eq!(resolve_profile_dir(&p), "release");
}
fn two_member_workspace() -> serde_json::Value {
serde_json::json!({
"target_directory": "/ws/target",
"packages": [
{
"name": "app",
"manifest_path": "/ws/app/Cargo.toml",
"targets": [{ "name": "app", "kind": ["bin"] }]
},
{
"name": "lib-only",
"manifest_path": "/ws/lib/Cargo.toml",
"targets": [{ "name": "lib_only", "kind": ["lib"] }]
}
]
})
}
#[test]
fn the_manifest_dir_is_inferred_without_a_package_flag() {
let resolved = resolve_binary_target(
&two_member_workspace(),
None,
Path::new("/ws"),
None,
&CargoProfile::from_release(true),
)
.expect("one binary member resolves");
assert_eq!(resolved.manifest_dir.as_deref(), Some(Path::new("/ws/app")));
assert!(resolved.path.starts_with("/ws/target/release"));
}
#[test]
fn the_manifest_dir_follows_an_explicit_package() {
let resolved = resolve_binary_target(
&two_member_workspace(),
Some("app"),
Path::new("/elsewhere"),
None,
&CargoProfile::default(),
)
.expect("named member resolves");
assert_eq!(resolved.manifest_dir.as_deref(), Some(Path::new("/ws/app")));
}
#[test]
fn an_ambiguous_workspace_still_errors() {
let mut metadata = two_member_workspace();
metadata["packages"][1]["targets"][0]["kind"] = serde_json::json!(["bin"]);
let error = resolve_binary_target(
&metadata,
None,
Path::new("/ws"),
None,
&CargoProfile::default(),
)
.expect_err("two binary members are ambiguous");
assert!(error.contains("multiple binary packages"), "{error}");
}
}