Skip to content

Commit 3aea00a

Browse files
committed
feat: implement namespace-aware config resolution and add man page rendering support
1 parent 595cbdc commit 3aea00a

File tree

5 files changed

+234
-5
lines changed

5 files changed

+234
-5
lines changed

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "apcore-cli"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition = "2021"
55
description = "Command-line interface for apcore modules"
66
license = "Apache-2.0"
@@ -21,7 +21,7 @@ name = "apcore_cli"
2121
path = "src/lib.rs"
2222

2323
[dependencies]
24-
apcore = ">=0.14"
24+
apcore = "=0.15.1"
2525
async-trait = "0.1"
2626
clap = { version = "4", features = ["derive", "env", "string"] }
2727
serde = { version = "1", features = ["derive"] }

src/cli.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -823,8 +823,10 @@ fn is_valid_module_id(s: &str) -> bool {
823823
/// * everything else (including `MODULE_EXECUTE_ERROR` / `MODULE_TIMEOUT`) → 1
824824
pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
825825
use crate::{
826-
EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_NOT_FOUND, EXIT_MODULE_EXECUTE_ERROR,
827-
EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR,
826+
EXIT_ACL_DENIED, EXIT_APPROVAL_DENIED, EXIT_CONFIG_BIND_ERROR, EXIT_CONFIG_MOUNT_ERROR,
827+
EXIT_CONFIG_NAMESPACE_RESERVED, EXIT_CONFIG_NOT_FOUND, EXIT_ERROR_FORMATTER_DUPLICATE,
828+
EXIT_MODULE_EXECUTE_ERROR, EXIT_MODULE_NOT_FOUND, EXIT_SCHEMA_CIRCULAR_REF,
829+
EXIT_SCHEMA_VALIDATION_ERROR,
828830
};
829831
match error_code {
830832
"MODULE_NOT_FOUND" | "MODULE_LOAD_ERROR" | "MODULE_DISABLED" => EXIT_MODULE_NOT_FOUND,
@@ -833,6 +835,13 @@ pub(crate) fn map_apcore_error_to_exit_code(error_code: &str) -> i32 {
833835
"CONFIG_NOT_FOUND" | "CONFIG_INVALID" => EXIT_CONFIG_NOT_FOUND,
834836
"SCHEMA_CIRCULAR_REF" => EXIT_SCHEMA_CIRCULAR_REF,
835837
"ACL_DENIED" => EXIT_ACL_DENIED,
838+
// Config Bus errors (apcore >= 0.15.0)
839+
"CONFIG_NAMESPACE_RESERVED"
840+
| "CONFIG_NAMESPACE_DUPLICATE"
841+
| "CONFIG_ENV_PREFIX_CONFLICT" => EXIT_CONFIG_NAMESPACE_RESERVED,
842+
"CONFIG_MOUNT_ERROR" => EXIT_CONFIG_MOUNT_ERROR,
843+
"CONFIG_BIND_ERROR" => EXIT_CONFIG_BIND_ERROR,
844+
"ERROR_FORMATTER_DUPLICATE" => EXIT_ERROR_FORMATTER_DUPLICATE,
836845
_ => EXIT_MODULE_EXECUTE_ERROR,
837846
}
838847
}

src/config.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ impl ConfigResolver {
4141
("cli.stdin_buffer_limit", "10485760"),
4242
("cli.auto_approve", "false"),
4343
("cli.help_text_max_length", "1000"),
44+
// Namespace-mode aliases (apcore >= 0.15.0 Config Bus)
45+
("apcore-cli.stdin_buffer_limit", "10485760"),
46+
("apcore-cli.auto_approve", "false"),
47+
("apcore-cli.help_text_max_length", "1000"),
48+
("apcore-cli.logging_level", "WARNING"),
49+
];
50+
51+
/// Namespace key → legacy key mapping for backward compatibility.
52+
const NAMESPACE_MAP: &'static [(&'static str, &'static str)] = &[
53+
("apcore-cli.stdin_buffer_limit", "cli.stdin_buffer_limit"),
54+
("apcore-cli.auto_approve", "cli.auto_approve"),
55+
(
56+
"apcore-cli.help_text_max_length",
57+
"cli.help_text_max_length",
58+
),
59+
("apcore-cli.logging_level", "logging.level"),
4460
];
4561

4662
/// Create a new `ConfigResolver`.
@@ -94,16 +110,36 @@ impl ConfigResolver {
94110
}
95111

96112
// Tier 3: Config file — key must be present in the flattened map.
113+
// Try both namespace and legacy keys for backward compatibility.
97114
if let Some(ref file_map) = self.config_file {
98115
if let Some(value) = file_map.get(key) {
99116
return Some(value.clone());
100117
}
118+
// Try alternate key (namespace ↔ legacy)
119+
if let Some(alt) = Self::alternate_key(key) {
120+
if let Some(value) = file_map.get(alt) {
121+
return Some(value.clone());
122+
}
123+
}
101124
}
102125

103126
// Tier 4: Built-in defaults.
104127
self.defaults.get(key).map(|s| s.to_string())
105128
}
106129

130+
/// Look up the alternate key (namespace ↔ legacy) for backward compatibility.
131+
fn alternate_key(key: &str) -> Option<&'static str> {
132+
for &(ns, legacy) in Self::NAMESPACE_MAP {
133+
if key == ns {
134+
return Some(legacy);
135+
}
136+
if key == legacy {
137+
return Some(ns);
138+
}
139+
}
140+
None
141+
}
142+
107143
/// Load and flatten a YAML config file into dot-notation keys.
108144
///
109145
/// Returns `None` if the file does not exist or cannot be parsed.
@@ -346,4 +382,116 @@ mod tests {
346382
let result = resolver.flatten_dict(map);
347383
assert_eq!(result.get("a.b.c"), Some(&"deep".to_string()));
348384
}
385+
386+
// ---- Namespace-aware config resolution (apcore >= 0.15.0) ----
387+
388+
#[test]
389+
fn test_defaults_contain_namespace_keys() {
390+
let resolver = ConfigResolver::new(None, None);
391+
for key in [
392+
"apcore-cli.stdin_buffer_limit",
393+
"apcore-cli.auto_approve",
394+
"apcore-cli.help_text_max_length",
395+
"apcore-cli.logging_level",
396+
] {
397+
assert!(
398+
resolver.defaults.contains_key(key),
399+
"missing namespace default: {key}"
400+
);
401+
}
402+
}
403+
404+
#[test]
405+
fn test_alternate_key_namespace_to_legacy() {
406+
assert_eq!(
407+
ConfigResolver::alternate_key("apcore-cli.stdin_buffer_limit"),
408+
Some("cli.stdin_buffer_limit")
409+
);
410+
assert_eq!(
411+
ConfigResolver::alternate_key("apcore-cli.auto_approve"),
412+
Some("cli.auto_approve")
413+
);
414+
assert_eq!(
415+
ConfigResolver::alternate_key("apcore-cli.logging_level"),
416+
Some("logging.level")
417+
);
418+
}
419+
420+
#[test]
421+
fn test_alternate_key_legacy_to_namespace() {
422+
assert_eq!(
423+
ConfigResolver::alternate_key("cli.stdin_buffer_limit"),
424+
Some("apcore-cli.stdin_buffer_limit")
425+
);
426+
assert_eq!(
427+
ConfigResolver::alternate_key("cli.auto_approve"),
428+
Some("apcore-cli.auto_approve")
429+
);
430+
assert_eq!(
431+
ConfigResolver::alternate_key("logging.level"),
432+
Some("apcore-cli.logging_level")
433+
);
434+
}
435+
436+
#[test]
437+
fn test_alternate_key_unknown_returns_none() {
438+
assert_eq!(ConfigResolver::alternate_key("unknown.key"), None);
439+
assert_eq!(ConfigResolver::alternate_key("extensions.root"), None);
440+
}
441+
442+
#[test]
443+
fn test_resolve_namespace_key_from_legacy_file() {
444+
// Simulate a config file with legacy "cli.stdin_buffer_limit" key
445+
let mut file_map = HashMap::new();
446+
file_map.insert("cli.stdin_buffer_limit".to_string(), "5242880".to_string());
447+
let resolver = ConfigResolver {
448+
cli_flags: HashMap::new(),
449+
config_file: Some(file_map),
450+
config_path: None,
451+
defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
452+
};
453+
// Querying the namespace key should find the legacy key via fallback
454+
let result = resolver.resolve("apcore-cli.stdin_buffer_limit", None, None);
455+
assert_eq!(result, Some("5242880".to_string()));
456+
}
457+
458+
#[test]
459+
fn test_resolve_legacy_key_from_namespace_file() {
460+
// Simulate a config file with namespace "apcore-cli.auto_approve" key
461+
let mut file_map = HashMap::new();
462+
file_map.insert("apcore-cli.auto_approve".to_string(), "true".to_string());
463+
let resolver = ConfigResolver {
464+
cli_flags: HashMap::new(),
465+
config_file: Some(file_map),
466+
config_path: None,
467+
defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
468+
};
469+
// Querying the legacy key should find the namespace key via fallback
470+
let result = resolver.resolve("cli.auto_approve", None, None);
471+
assert_eq!(result, Some("true".to_string()));
472+
}
473+
474+
#[test]
475+
fn test_direct_key_takes_precedence_over_alternate() {
476+
let mut file_map = HashMap::new();
477+
file_map.insert("cli.help_text_max_length".to_string(), "500".to_string());
478+
file_map.insert(
479+
"apcore-cli.help_text_max_length".to_string(),
480+
"2000".to_string(),
481+
);
482+
let resolver = ConfigResolver {
483+
cli_flags: HashMap::new(),
484+
config_file: Some(file_map),
485+
config_path: None,
486+
defaults: ConfigResolver::DEFAULTS.iter().copied().collect(),
487+
};
488+
assert_eq!(
489+
resolver.resolve("cli.help_text_max_length", None, None),
490+
Some("500".to_string())
491+
);
492+
assert_eq!(
493+
resolver.resolve("apcore-cli.help_text_max_length", None, None),
494+
Some("2000".to_string())
495+
);
496+
}
349497
}

src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ pub const EXIT_APPROVAL_DENIED: i32 = 46;
3030
pub const EXIT_CONFIG_NOT_FOUND: i32 = 47;
3131
pub const EXIT_SCHEMA_CIRCULAR_REF: i32 = 48;
3232
pub const EXIT_ACL_DENIED: i32 = 77;
33+
// Config Bus errors (apcore >= 0.15.0)
34+
pub const EXIT_CONFIG_NAMESPACE_RESERVED: i32 = 78;
35+
pub const EXIT_CONFIG_NAMESPACE_DUPLICATE: i32 = 78;
36+
pub const EXIT_CONFIG_ENV_PREFIX_CONFLICT: i32 = 78;
37+
pub const EXIT_CONFIG_MOUNT_ERROR: i32 = 66;
38+
pub const EXIT_CONFIG_BIND_ERROR: i32 = 65;
39+
pub const EXIT_ERROR_FORMATTER_DUPLICATE: i32 = 70;
3340
pub const EXIT_SIGINT: i32 = 130;
3441

3542
// Re-export primary public types at crate root.

src/main.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,67 @@ pub fn extract_binding_path(args: &[String]) -> Option<String> {
6565
extract_argv_option(args, "--binding")
6666
}
6767

68+
// ---------------------------------------------------------------------------
69+
// render_man_page
70+
// ---------------------------------------------------------------------------
71+
72+
/// Render a roff man page to stdout.
73+
///
74+
/// When stdout is a TTY, attempts to render through `mandoc` or `groff` and
75+
/// pipe through a pager for formatted display. When stdout is not a TTY
76+
/// (piped or redirected), outputs raw roff for file redirection.
77+
fn render_man_page(roff: &str) {
78+
use std::io::{IsTerminal, Write};
79+
use std::process::{Command, Stdio};
80+
81+
let is_tty = std::io::stdout().is_terminal();
82+
if !is_tty {
83+
print!("{roff}");
84+
return;
85+
}
86+
87+
// Try mandoc first (macOS/BSD), then groff.
88+
let renderers: &[(&str, &[&str])] = &[("mandoc", &["-a"]), ("groff", &["-man", "-Tutf8"])];
89+
for &(cmd, args) in renderers {
90+
let Ok(mut child) = Command::new(cmd)
91+
.args(args)
92+
.stdin(Stdio::piped())
93+
.stdout(Stdio::piped())
94+
.stderr(Stdio::null())
95+
.spawn()
96+
else {
97+
continue;
98+
};
99+
if let Some(mut stdin) = child.stdin.take() {
100+
let _ = stdin.write_all(roff.as_bytes());
101+
}
102+
let Ok(output) = child.wait_with_output() else {
103+
continue;
104+
};
105+
if !output.status.success() || output.stdout.is_empty() {
106+
continue;
107+
}
108+
// Pipe rendered output through PAGER or less.
109+
let pager = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
110+
if let Ok(mut pager_child) = Command::new(&pager)
111+
.arg("-R")
112+
.stdin(Stdio::piped())
113+
.stdout(Stdio::inherit())
114+
.stderr(Stdio::inherit())
115+
.spawn()
116+
{
117+
if let Some(mut stdin) = pager_child.stdin.take() {
118+
let _ = stdin.write_all(&output.stdout);
119+
}
120+
let _ = pager_child.wait();
121+
return;
122+
}
123+
}
124+
125+
// Fallback: raw roff output.
126+
print!("{roff}");
127+
}
128+
68129
// ---------------------------------------------------------------------------
69130
// resolve_prog_name
70131
// ---------------------------------------------------------------------------
@@ -182,6 +243,10 @@ fn build_cli_command(
182243
.version(env!("CARGO_PKG_VERSION"))
183244
.long_version(format!("{}, version {}", name, env!("CARGO_PKG_VERSION")))
184245
.about("CLI adapter for the apcore module ecosystem.")
246+
.after_help(
247+
"Use --help --verbose to show all options (including built-in apcore options).\n\
248+
Use --help --man to display a formatted man page.",
249+
)
185250
.allow_external_subcommands(true)
186251
.arg(
187252
clap::Arg::new("extensions-dir")
@@ -287,7 +352,7 @@ async fn main() {
287352
None,
288353
None,
289354
);
290-
println!("{roff}");
355+
render_man_page(&roff);
291356
std::process::exit(0);
292357
}
293358

0 commit comments

Comments
 (0)