Skip to content
Open
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
291 changes: 276 additions & 15 deletions src/bin/uudoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
use std::{
collections::HashMap,
ffi::OsString,
fs::File,
fs::{self, File},
io::{self, Read, Seek, Write},
path::Path,
process,
};

Expand Down Expand Up @@ -249,7 +250,7 @@ fn main() -> io::Result<()> {
let utils = util_map::<Box<dyn Iterator<Item = OsString>>>();
// Initialize localization for uucore common strings (used by tldr example attribution)
let _ = uucore::locale::setup_localization("uudoc");
match std::fs::create_dir("docs/src/utils/") {
match fs::create_dir("docs/src/utils/") {
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
x => x,
}?;
Expand Down Expand Up @@ -350,6 +351,12 @@ fn main() -> io::Result<()> {
}
}

// Load all FTL files from all utility directories into a combined string.
// This allows utilities that share keys across crates (e.g., sha224sum using
// ck-common-help-* from checksum_common, or base64 using base-common-help-*
// from base32) to resolve those keys during doc generation.
let all_fluent = load_all_ftl("src/uu");

println!("Writing to utils");
for (&name, (_, command)) in utils {
let (utils_name, usage_name, command) = match name {
Expand All @@ -360,13 +367,11 @@ fn main() -> io::Result<()> {
};
let p = format!("docs/src/utils/{usage_name}.md");

let fluent = File::open(format!("src/uu/{utils_name}/locales/en-US.ftl"))
.and_then(|mut f: File| {
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
})
.ok();
let fluent = if all_fluent.is_empty() {
None
} else {
Some(all_fluent.clone())
};

if let Ok(f) = File::create(&p) {
MDWriter {
Expand All @@ -388,6 +393,26 @@ fn main() -> io::Result<()> {
Ok(())
}

/// Load and concatenate all `en-US.ftl` files from utility locale directories.
/// This allows shared Fluent keys (e.g., `ck-common-help-*` in `checksum_common`)
/// to be resolved when generating docs for utilities that depend on them.
fn load_all_ftl(uu_dir: &str) -> String {
let mut combined = String::new();
let base = Path::new(uu_dir);
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let ftl_path = entry.path().join("locales/en-US.ftl");
if ftl_path.is_file() {
if let Ok(content) = fs::read_to_string(&ftl_path) {
combined.push('\n');
combined.push_str(&content);
}
}
}
}
combined
}

fn fix_usage(name: &str, usage: String) -> String {
match name {
"test" => {
Expand Down Expand Up @@ -615,12 +640,10 @@ impl MDWriter<'_, '_> {
}
writeln!(self.w, "</dt>")?;
let help_text = arg.get_help().unwrap_or_default().to_string();
// Try to resolve Fluent key if it looks like one, otherwise use as-is
let resolved_help = if help_text.starts_with(&format!("{}-help-", self.fluent_key)) {
self.extract_fluent_value(&help_text).unwrap_or(help_text)
} else {
help_text
};
// Try to resolve Fluent key from the FTL file, otherwise use help text as-is.
// We always attempt resolution because shared keys (e.g. "base-common-help-*")
// don't necessarily start with the utility-specific prefix.
let resolved_help = self.extract_fluent_value(&help_text).unwrap_or(help_text);
writeln!(
self.w,
"<dd>\n\n{}\n\n</dd>",
Expand Down Expand Up @@ -856,4 +879,242 @@ mod tests {
let result = post_process_manpage(input.to_string(), "2024-01-01");
assert_eq!(result, expected);
}

/// Helper to create an MDWriter with given FTL content for testing
fn make_test_writer(fluent_content: &str, fluent_key: &str) -> MDWriter<'static, 'static> {
// Leak the HashMap to get a 'static reference (fine in tests)
let platforms: &'static HashMap<&'static str, Vec<String>> =
Box::leak(Box::new(HashMap::new()));
let tldr_zip: &'static mut Option<ZipArchive<File>> = Box::leak(Box::new(None));
MDWriter {
w: Box::new(Vec::new()),
command: Command::new("test"),
name: "test",
tldr_zip,
utils_per_platform: platforms,
fluent: Some(fluent_content.to_string()),
fluent_key: fluent_key.to_string(),
}
}

#[test]
fn test_extract_fluent_value_resolves_utility_specific_keys() {
let ftl = "base32-about = encode/decode data and print to standard output\n\
base32-usage = base32 [OPTION]... [FILE]\n";
let writer = make_test_writer(ftl, "base32");

assert_eq!(
writer.extract_fluent_value("base32-about"),
Some("encode/decode data and print to standard output".to_string())
);
assert_eq!(
writer.extract_fluent_value("base32-usage"),
Some("base32 [OPTION]... [FILE]".to_string())
);
}

#[test]
fn test_extract_fluent_value_resolves_shared_keys() {
// Regression test: shared Fluent keys like "base-common-help-decode"
// don't start with the utility prefix "base32-". They must still be
// resolved from the same FTL file.
let ftl = "base32-about = encode/decode data\n\
base-common-help-decode = decode data\n\
base-common-help-ignore-garbage = when decoding, ignore non-alphabet characters\n";
let writer = make_test_writer(ftl, "base32");

assert_eq!(
writer.extract_fluent_value("base-common-help-decode"),
Some("decode data".to_string())
);
assert_eq!(
writer.extract_fluent_value("base-common-help-ignore-garbage"),
Some("when decoding, ignore non-alphabet characters".to_string())
);
}

#[test]
fn test_extract_fluent_value_returns_none_for_missing_keys() {
let ftl = "base32-about = encode/decode data\n";
let writer = make_test_writer(ftl, "base32");

assert_eq!(writer.extract_fluent_value("nonexistent-key"), None);
}

#[test]
fn test_extract_fluent_value_returns_none_when_no_ftl() {
let platforms: &'static HashMap<&'static str, Vec<String>> =
Box::leak(Box::new(HashMap::new()));
let tldr_zip: &'static mut Option<ZipArchive<File>> = Box::leak(Box::new(None));
let writer = MDWriter {
w: Box::new(Vec::new()),
command: Command::new("test"),
name: "test",
tldr_zip,
utils_per_platform: platforms,
fluent: None,
fluent_key: "test".to_string(),
};

assert_eq!(writer.extract_fluent_value("any-key"), None);
}

/// A shared buffer that implements Write for use in tests.
#[derive(Clone)]
struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);

impl SharedBuf {
fn new() -> Self {
Self(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())))
}
fn to_string(&self) -> String {
String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
}
}

impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

#[test]
fn test_options_resolves_shared_fluent_keys_in_help_text() {
// End-to-end test: an option whose help text is a shared Fluent key
// must have that key resolved in the generated markdown output.
let ftl = "base-common-help-decode = decode data\n";
let command = Command::new("base32").arg(
Arg::new("decode")
.short('d')
.long("decode")
.help("base-common-help-decode")
.action(clap::ArgAction::SetTrue),
);
let platforms: &'static HashMap<&'static str, Vec<String>> =
Box::leak(Box::new(HashMap::new()));
let tldr_zip: &'static mut Option<ZipArchive<File>> = Box::leak(Box::new(None));
let buf = SharedBuf::new();
let mut writer = MDWriter {
w: Box::new(buf.clone()),
command,
name: "base32",
tldr_zip,
utils_per_platform: platforms,
fluent: Some(ftl.to_string()),
fluent_key: "base32".to_string(),
};

writer.options().unwrap();
let html = buf.to_string();

// The resolved text "decode data" must appear, NOT the raw key
assert!(
html.contains("decode data"),
"Expected resolved help text 'decode data', got:\n{html}"
);
assert!(
!html.contains("base-common-help-decode"),
"Raw Fluent key should not appear in output, got:\n{html}"
);
}

#[test]
fn test_options_resolves_cross_crate_fluent_keys() {
// Simulates sha224sum: its own FTL only has sha224sum-about/usage,
// but options use ck-common-help-* keys from checksum_common's FTL.
// When all FTL files are combined, these keys must resolve.
let combined_ftl = "\
sha224sum-about = Print or check the SHA224 checksums
sha224sum-usage = sha224sum [OPTIONS] [FILE]...
ck-common-help-check = read checksums from the FILEs and check them
ck-common-help-warn = warn about improperly formatted checksum lines
ck-common-help-status = don't output anything, status code shows success
";
let command = Command::new("sha224sum")
.arg(
Arg::new("check")
.short('c')
.long("check")
.help("ck-common-help-check")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("warn")
.short('w')
.long("warn")
.help("ck-common-help-warn")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("status")
.long("status")
.help("ck-common-help-status")
.action(clap::ArgAction::SetTrue),
);
let platforms: &'static HashMap<&'static str, Vec<String>> =
Box::leak(Box::new(HashMap::new()));
let tldr_zip: &'static mut Option<ZipArchive<File>> = Box::leak(Box::new(None));
let buf = SharedBuf::new();
let mut writer = MDWriter {
w: Box::new(buf.clone()),
command,
name: "sha224sum",
tldr_zip,
utils_per_platform: platforms,
fluent: Some(combined_ftl.to_string()),
fluent_key: "sha224sum".to_string(),
};

writer.options().unwrap();
let html = buf.to_string();

assert!(
html.contains("read checksums from the FILEs and check them"),
"Expected resolved ck-common-help-check, got:\n{html}"
);
assert!(
!html.contains("ck-common-help-check"),
"Raw Fluent key ck-common-help-check should not appear, got:\n{html}"
);
assert!(
html.contains("warn about improperly formatted checksum lines"),
"Expected resolved ck-common-help-warn, got:\n{html}"
);
assert!(
html.contains("don't output anything, status code shows success"),
"Expected resolved ck-common-help-status, got:\n{html}"
);
}

#[test]
fn test_load_all_ftl_combines_files() {
// Create a temporary directory structure mirroring src/uu/*/locales/
let tmp = std::env::temp_dir().join("uudoc_test_ftl");
let _ = fs::remove_dir_all(&tmp);

let util_a = tmp.join("util_a/locales");
let util_b = tmp.join("util_b/locales");
fs::create_dir_all(&util_a).unwrap();
fs::create_dir_all(&util_b).unwrap();

fs::write(util_a.join("en-US.ftl"), "key-a = value A\n").unwrap();
fs::write(util_b.join("en-US.ftl"), "key-b = value B\n").unwrap();

let combined = load_all_ftl(tmp.to_str().unwrap());

assert!(combined.contains("key-a = value A"), "Missing key-a");
assert!(combined.contains("key-b = value B"), "Missing key-b");

// Clean up
let _ = fs::remove_dir_all(&tmp);
}

#[test]
fn test_load_all_ftl_handles_missing_dir() {
let result = load_all_ftl("/nonexistent/path");
assert!(result.is_empty());
}
}
Loading