Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bc80acf
docs(execplans): add detailed ExecPlan for CliConfig schema introduction
leynos Mar 8, 2026
3754d2c
feat(docs): upgrade ortho_config docs and embed v0.8.0 migration guid…
leynos Mar 8, 2026
4218808
feat(cli): introduce typed CliConfig schema for layered config merging
leynos Mar 9, 2026
a97e795
test(cli): refactor CLI merge layer tests for reusability and clarity
leynos Mar 10, 2026
b31734e
test(cli): refactor merge.rs tests with helper assertion functions
leynos Mar 11, 2026
19c642a
test(cli): use parameterized tests for conflict validation
leynos Mar 11, 2026
e0d4432
test(cli): improve merge tests with structured error expectations
leynos Mar 12, 2026
2412247
feat(cli): introduce CliConfig as central OrthoConfig-derived schema
leynos Mar 12, 2026
6533f68
docs(users-guide): fix comma usage in users guide for clarity
leynos Mar 13, 2026
456152c
style: format rebased cli changes
leynos Mar 14, 2026
d3f248f
refactor(cli): split CLI parsing and config to separate modules
leynos Mar 14, 2026
528fa6b
Add explicit CLI overrides with typed enums; update tests/docs/build …
leynos Mar 23, 2026
c52a6e6
Reconcile CLI config after rebase
leynos May 26, 2026
e7a065f
Deduplicate CLI merge validation tests
leynos May 27, 2026
bb051e0
Bundle enum parser localisation metadata in ParseEnumSpec
leynos May 27, 2026
56397fc
Extract diag_json resolution helpers in merge.rs
leynos May 27, 2026
151c0cb
Flatten push_file_layers layer loading match
leynos May 27, 2026
cedb2b2
Declare OrthoConfig root type for cargo-orthohelp
leynos May 27, 2026
ffbf353
Type merge rejection expectations in CLI tests
leynos May 27, 2026
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
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 5 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,8 @@ license = "ISC"
description = "A YAML-powered Ninja/Jinja hybrid build system."

[package.metadata.ortho_config]
root_type = "netsuke::cli::Cli"
locales = ["en-US", "es-ES"]

[package.metadata.ortho_config.windows]
module_name = "Netsuke"
include_common_parameters = true
split_subcommands_into_functions = false
root_type = "netsuke::cli::CliConfig"
locales = ["en-US", "es-ES"]

[features]
default = []
Expand All @@ -31,7 +26,7 @@ legacy-digests = ["sha1", "md5"]
clap = { version = "4.5.0", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde-saphyr = "0.0.6"
minijinja = { version = "2.18.0", features = ["loader"] }
minijinja = { version = "2.12.0", features = ["loader"] }
cap-std = { version = "3.4.4", features = ["fs_utf8"] }
camino = "1.2.0"
semver = { version = "1", features = ["serde"] }
Expand Down Expand Up @@ -66,10 +61,12 @@ sys-locale = "0.3.2"

[build-dependencies]
clap = { version = "4.5.0", features = ["derive"] }
clap_mangen = "0.2.29"
ortho_config = { version = "0.8.0", features = ["serde_json"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
thiserror = "1"
time = { version = "0.3.44", features = ["formatting"] }
tracing = "0.1"

[lints.clippy]
Expand Down
170 changes: 142 additions & 28 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
//! Build script for Netsuke.
//!
//! This script audits localization keys declared in
//! `src/localization/keys.rs` against the Fluent bundles in
//! `locales/*/messages.ftl`, failing the build if any declared key is missing
//! from a locale.
use clap::ArgMatches;
use std::{ffi::OsString, sync::Arc};
//! This script performs two main tasks:
//! - Generate the CLI manual page into `target/generated-man/<target>/<profile>` for release
//! packaging.
//! - Audit localization keys declared in `src/localization/keys.rs` against the Fluent bundles
//! in `locales/*/messages.ftl`, failing the build if any declared key is missing from a
//! locale.
use clap::{ArgMatches, CommandFactory};
use clap_mangen::Man;
use std::{
env,
ffi::OsString,
fs,
path::{Path, PathBuf},
sync::Arc,
};
use time::{OffsetDateTime, format_description::well_known::Iso8601};

const FALLBACK_DATE: &str = "1970-01-01";

type OutputModeResolveWith = fn(
Option<bool>,
Option<cli::ColourPolicy>,
fn(&str) -> Option<String>,
) -> output_mode::OutputMode;

#[path = "src/cli/mod.rs"]
mod cli;
Expand Down Expand Up @@ -43,49 +61,145 @@ type ResolveThemeFn = fn(
fn(&str) -> Option<String>,
) -> theme::ResolvedTheme;

type ThemeContextCtor = fn(
Option<bool>,
Option<cli::config::ColourPolicy>,
output_mode::OutputMode,
) -> theme::ThemeContext;
type ResolveMergedDiagJsonFn = fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult<bool>;
type MergeWithConfigFn = fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult<cli::Cli>;

/// Anchors all shared-module symbols so they remain linked when the build script is compiled
/// without tests.
const fn assert_symbols_linked() {
fn manual_date() -> String {
let Ok(raw) = env::var("SOURCE_DATE_EPOCH") else {
return FALLBACK_DATE.into();
};

let Ok(ts) = raw.parse::<i64>() else {
println!(
"cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; expected integer seconds since Unix epoch; falling back to {FALLBACK_DATE}"
);
return FALLBACK_DATE.into();
};

let Ok(dt) = OffsetDateTime::from_unix_timestamp(ts) else {
println!(
"cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; not a valid Unix timestamp; falling back to {FALLBACK_DATE}"
);
return FALLBACK_DATE.into();
};

dt.format(&Iso8601::DATE).unwrap_or_else(|_| {
println!(
"cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; formatting failed; falling back to {FALLBACK_DATE}"
);
FALLBACK_DATE.into()
})
}

fn out_dir_for_target_profile() -> PathBuf {
let target = env::var("TARGET").unwrap_or_else(|_| "unknown-target".into());
let profile = env::var("PROFILE").unwrap_or_else(|_| "unknown-profile".into());
PathBuf::from(format!("target/generated-man/{target}/{profile}"))
}

fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result<PathBuf> {
fs::create_dir_all(dir)?;
let destination = dir.join(page_name);
let tmp = dir.join(format!("{page_name}.tmp"));
fs::write(&tmp, data)?;
if destination.exists() {
fs::remove_file(&destination)?;
}
fs::rename(&tmp, &destination)?;
Ok(destination)
}

const fn verify_public_api_symbols() {
// Exercise CLI localization, config merge, and host pattern symbols so the
// shared modules remain linked when the build script is compiled without
// tests.
const _: usize = std::mem::size_of::<cli::BuildArgs>();
const _: usize = std::mem::size_of::<cli::CliConfig>();
const _: usize = std::mem::size_of::<cli::Commands>();
const _: usize = std::mem::size_of::<cli::Theme>();
const _: usize = std::mem::size_of::<HostPattern>();
const _: fn(&[OsString]) -> Option<String> = cli::locale_hint_from_args;
const _: fn(&[OsString]) -> Option<bool> = cli::diag_json_hint_from_args;
const _: fn(&str) -> Option<bool> = cli_l10n::parse_bool_hint;
const _: ResolveMergedDiagJsonFn = cli::resolve_merged_diag_json;
const _: MergeWithConfigFn = cli::merge_with_config;
const _: fn(&cli::Cli, &ArgMatches) -> bool = cli::resolve_merged_diag_json;
const _: fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult<cli::Cli> =
cli::merge_with_config;
const _: LocalizedParseFn = cli::parse_with_localizer_from;
const _: fn(&cli::Cli) -> cli::config::CliConfig = cli::Cli::config;
const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_diag_json;
const _: fn(&cli::Cli) -> Option<bool> = cli::Cli::no_emoji_override;
const _: fn(&cli::Cli) -> bool = cli::Cli::progress_enabled;
const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_progress;
const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_diag_json;
const _: fn(&str) -> Result<HostPattern, HostPatternError> = HostPattern::parse;
const _: fn(&HostPattern, host_pattern::HostCandidate<'_>) -> bool = HostPattern::matches;
const _: fn(Option<bool>, Option<cli::config::ColourPolicy>) -> output_mode::OutputMode =
const _: fn(Option<bool>, Option<cli::ColourPolicy>) -> output_mode::OutputMode =
output_mode::resolve;
const _: fn(&cli::config::CliConfig) -> bool = cli::config::CliConfig::resolved_diag_json;
const _: fn(&cli::config::CliConfig) -> bool = cli::config::CliConfig::resolved_progress;
const _: ThemeContextCtor = theme::ThemeContext::new;
const _: OutputModeResolveWith = output_mode::resolve_with;
const _: fn(
Option<bool>,
Option<cli::ColourPolicy>,
output_mode::OutputMode,
) -> theme::ThemeContext = theme::ThemeContext::new;
const _: ResolveThemeFn = theme::resolve_theme;
}

/// Emits Cargo rerun directives for all inputs that affect the build output.
fn emit_rerun_directives() {
println!("cargo:rerun-if-changed=src/cli/mod.rs");
println!("cargo:rerun-if-changed=src/cli/config.rs");
println!("cargo:rerun-if-changed=src/cli/merge.rs");
println!("cargo:rerun-if-changed=src/cli/parser.rs");
println!("cargo:rerun-if-changed=src/cli/parsing.rs");
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
println!("cargo:rerun-if-env-changed=CARGO_PKG_NAME");
println!("cargo:rerun-if-env-changed=CARGO_BIN_NAME");
println!("cargo:rerun-if-env-changed=CARGO_PKG_DESCRIPTION");
println!("cargo:rerun-if-env-changed=CARGO_PKG_AUTHORS");
println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
println!("cargo:rerun-if-env-changed=TARGET");
println!("cargo:rerun-if-env-changed=PROFILE");
println!("cargo:rerun-if-changed=src/localization/keys.rs");
println!("cargo:rerun-if-changed=locales/en-US/messages.ftl");
println!("cargo:rerun-if-changed=locales/es-ES/messages.ftl");
}

fn generate_man_page(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let cmd = cli::Cli::command();
let name = cmd
.get_bin_name()
.unwrap_or_else(|| cmd.get_name())
.to_owned();
let cargo_bin = env::var("CARGO_BIN_NAME")
.or_else(|_| env::var("CARGO_PKG_NAME"))
.unwrap_or_else(|_| name.clone());
if name != cargo_bin {
return Err(format!(
"CLI name {name} differs from Cargo bin/package name {cargo_bin}; packaging expects {cargo_bin}.1"
)
.into());
}
let version = env::var("CARGO_PKG_VERSION").map_err(
|_| "CARGO_PKG_VERSION must be set by Cargo; cannot render manual page without it.",
)?;
let man = Man::new(cmd)
.section("1")
.source(format!("{cargo_bin} {version}"))
.date(manual_date());
let mut buf = Vec::new();
man.render(&mut buf)?;
let page_name = format!("{cargo_bin}.1");
write_man_page(&buf, out_dir, &page_name)?;
if let Some(extra_dir) = env::var_os("OUT_DIR") {
let extra_dir_path = PathBuf::from(extra_dir);
if let Err(err) = write_man_page(&buf, &extra_dir_path, &page_name) {
println!(
"cargo:warning=Failed to stage manual page in OUT_DIR ({}): {err}",
extra_dir_path.display()
);
}
}
Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
assert_symbols_linked();
verify_public_api_symbols();
emit_rerun_directives();
build_l10n_audit::audit_localization_keys()?;
Ok(())
let out_dir = out_dir_for_target_profile();
generate_man_page(&out_dir)
}
Loading
Loading