cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit af3f1a213c721b7d6d9ec33a1fcd4cf073c72f73
parent 5c0844bdee59e50c7b1b50c71bca9ad27696acce
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 02:51:48 +0000

freeze cli v1 taxonomy and roots

Diffstat:
Msrc/cli.rs | 313++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/commands/identity.rs | 20++++++++++----------
Msrc/commands/mod.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/commands/runtime.rs | 16+++++++++++-----
Msrc/domain/runtime.rs | 28++++++++++++++++++----------
Msrc/render/mod.rs | 80++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/runtime/config.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/identity_commands.rs | 30+++++++++++++++---------------
Mtests/runtime_show.rs | 47++++++++++++++++++++++++++++++++++++-----------
Mtests/signer_status.rs | 6+++---
10 files changed, 577 insertions(+), 158 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -29,33 +29,50 @@ pub struct CliArgs { #[derive(Debug, Clone, Subcommand)] pub enum Command { - Identity(IdentityArgs), + Account(AccountArgs), + Config(ConfigArgs), + Doctor, + Find(FindArgs), + Job(JobArgs), + Listing(ListingArgs), + Local(LocalArgs), Myc(MycArgs), - Runtime(RuntimeArgs), + Net(NetArgs), + Order(OrderArgs), + Relay(RelayArgs), + Rpc(RpcArgs), Signer(SignerArgs), + Sync(SyncArgs), } #[derive(Debug, Clone, Args)] -pub struct RuntimeArgs { +pub struct ConfigArgs { #[command(subcommand)] - pub command: RuntimeCommand, + pub command: ConfigCommand, } #[derive(Debug, Clone, Subcommand)] -pub enum RuntimeCommand { +pub enum ConfigCommand { Show, } #[derive(Debug, Clone, Args)] -pub struct IdentityArgs { +pub struct AccountArgs { #[command(subcommand)] - pub command: IdentityCommand, + pub command: AccountCommand, } #[derive(Debug, Clone, Subcommand)] -pub enum IdentityCommand { - Init, - Show, +pub enum AccountCommand { + New, + Whoami, + Ls, + Use(AccountUseArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct AccountUseArgs { + pub selector: String, } #[derive(Debug, Clone, Args)] @@ -80,21 +97,141 @@ pub enum SignerCommand { Status, } +#[derive(Debug, Clone, Args)] +pub struct RelayArgs { + #[command(subcommand)] + pub command: RelayCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum RelayCommand { + Ls, +} + +#[derive(Debug, Clone, Args)] +pub struct NetArgs { + #[command(subcommand)] + pub command: NetCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum NetCommand { + Status, +} + +#[derive(Debug, Clone, Args)] +pub struct LocalArgs { + #[command(subcommand)] + pub command: LocalCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum LocalCommand { + Init, + Status, + Export, + Backup, +} + +#[derive(Debug, Clone, Args)] +pub struct SyncArgs { + #[command(subcommand)] + pub command: SyncCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum SyncCommand { + Status, + Pull, + Push, + Watch, +} + +#[derive(Debug, Clone, Args)] +pub struct FindArgs { + #[arg(value_name = "query", num_args = 1..)] + pub query: Vec<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct ListingArgs { + #[command(subcommand)] + pub command: ListingCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ListingCommand { + New, + Validate, + Get(RecordKeyArgs), + Publish, + Update(RecordKeyArgs), + Archive(RecordKeyArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct JobArgs { + #[command(subcommand)] + pub command: JobCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum JobCommand { + Ls, + Get(RecordKeyArgs), + Watch(RecordKeyArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct RpcArgs { + #[command(subcommand)] + pub command: RpcCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum RpcCommand { + Status, + Sessions, +} + +#[derive(Debug, Clone, Args)] +pub struct OrderArgs { + #[command(subcommand)] + pub command: OrderCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderCommand { + New, + Get(RecordKeyArgs), + Ls, + Submit, + Watch(RecordKeyArgs), + Cancel(RecordKeyArgs), + History, +} + +#[derive(Debug, Clone, Args)] +pub struct RecordKeyArgs { + pub key: String, +} + #[cfg(test)] mod tests { - use super::{CliArgs, Command, IdentityCommand, MycCommand, RuntimeCommand, SignerCommand}; + use super::{ + AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, + MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand, + }; use clap::Parser; #[test] - fn parses_runtime_show_command() { - let parsed = CliArgs::parse_from(["radroots", "runtime", "show"]); + fn parses_config_show_command() { + let parsed = CliArgs::parse_from(["radroots", "config", "show"]); match parsed.command { - Command::Identity(_) | Command::Myc(_) | Command::Signer(_) => { - panic!("unexpected command variant") - } - Command::Runtime(runtime) => match runtime.command { - RuntimeCommand::Show => {} + Command::Config(config) => match config.command { + ConfigCommand::Show => {} }, + _ => panic!("unexpected command variant"), } } @@ -116,7 +253,7 @@ mod tests { "myc", "--myc-executable", "bin/myc", - "runtime", + "config", "show", ]); assert!(parsed.json); @@ -151,27 +288,41 @@ mod tests { } #[test] - fn parses_identity_commands() { - let init = CliArgs::parse_from(["radroots", "identity", "init"]); - match init.command { - Command::Identity(identity) => match identity.command { - IdentityCommand::Init => {} - IdentityCommand::Show => panic!("unexpected identity subcommand"), + fn parses_account_commands() { + let new = CliArgs::parse_from(["radroots", "account", "new"]); + match new.command { + Command::Account(account) => match account.command { + AccountCommand::New => {} + _ => panic!("unexpected account subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let whoami = CliArgs::parse_from(["radroots", "account", "whoami"]); + match whoami.command { + Command::Account(account) => match account.command { + AccountCommand::Whoami => {} + _ => panic!("unexpected account subcommand"), }, - Command::Myc(_) | Command::Runtime(_) | Command::Signer(_) => { - panic!("unexpected command variant") - } + _ => panic!("unexpected command variant"), } - let show = CliArgs::parse_from(["radroots", "identity", "show"]); - match show.command { - Command::Identity(identity) => match identity.command { - IdentityCommand::Show => {} - IdentityCommand::Init => panic!("unexpected identity subcommand"), + let ls = CliArgs::parse_from(["radroots", "account", "ls"]); + match ls.command { + Command::Account(account) => match account.command { + AccountCommand::Ls => {} + _ => panic!("unexpected account subcommand"), }, - Command::Myc(_) | Command::Runtime(_) | Command::Signer(_) => { - panic!("unexpected command variant") - } + _ => panic!("unexpected command variant"), + } + + let use_account = CliArgs::parse_from(["radroots", "account", "use", "market-main"]); + match use_account.command { + Command::Account(account) => match account.command { + AccountCommand::Use(args) => assert_eq!(args.selector, "market-main"), + _ => panic!("unexpected account subcommand"), + }, + _ => panic!("unexpected command variant"), } } @@ -182,9 +333,7 @@ mod tests { Command::Signer(signer) => match signer.command { SignerCommand::Status => {} }, - Command::Identity(_) | Command::Myc(_) | Command::Runtime(_) => { - panic!("unexpected command variant") - } + _ => panic!("unexpected command variant"), } } @@ -195,9 +344,89 @@ mod tests { Command::Myc(myc) => match myc.command { MycCommand::Status => {} }, - Command::Identity(_) | Command::Runtime(_) | Command::Signer(_) => { - panic!("unexpected command variant") - } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parses_v1_command_skeleton() { + let doctor = CliArgs::parse_from(["radroots", "doctor"]); + assert!(matches!(doctor.command, Command::Doctor)); + + let find = CliArgs::parse_from(["radroots", "find", "tomatoes"]); + match find.command { + Command::Find(args) => assert_eq!(args.query, vec!["tomatoes"]), + _ => panic!("unexpected command variant"), + } + + let relay = CliArgs::parse_from(["radroots", "relay", "ls"]); + match relay.command { + Command::Relay(args) => match args.command { + RelayCommand::Ls => {} + }, + _ => panic!("unexpected command variant"), + } + + let net = CliArgs::parse_from(["radroots", "net", "status"]); + match net.command { + Command::Net(args) => match args.command { + NetCommand::Status => {} + }, + _ => panic!("unexpected command variant"), + } + + let local = CliArgs::parse_from(["radroots", "local", "init"]); + match local.command { + Command::Local(args) => match args.command { + LocalCommand::Init => {} + _ => panic!("unexpected local subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let sync = CliArgs::parse_from(["radroots", "sync", "status"]); + match sync.command { + Command::Sync(args) => match args.command { + SyncCommand::Status => {} + _ => panic!("unexpected sync subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let listing = CliArgs::parse_from(["radroots", "listing", "get", "lst_123"]); + match listing.command { + Command::Listing(args) => match args.command { + ListingCommand::Get(key) => assert_eq!(key.key, "lst_123"), + _ => panic!("unexpected listing subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let job = CliArgs::parse_from(["radroots", "job", "get", "job_123"]); + match job.command { + Command::Job(args) => match args.command { + JobCommand::Get(key) => assert_eq!(key.key, "job_123"), + _ => panic!("unexpected job subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let rpc = CliArgs::parse_from(["radroots", "rpc", "status"]); + match rpc.command { + Command::Rpc(args) => match args.command { + RpcCommand::Status => {} + _ => panic!("unexpected rpc subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let order = CliArgs::parse_from(["radroots", "order", "history"]); + match order.command { + Command::Order(args) => match args.command { + OrderCommand::History => {} + _ => panic!("unexpected order subcommand"), + }, + _ => panic!("unexpected command variant"), } } } diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -1,15 +1,15 @@ use crate::domain::runtime::{ - CommandDisposition, CommandOutput, CommandView, IdentityInitView, IdentityPublicView, - IdentityShowView, + AccountNewView, AccountWhoamiView, CommandDisposition, CommandOutput, CommandView, + IdentityPublicView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::identity::{initialize_identity, load_identity}; use radroots_identity::IdentityError; -pub fn init(config: &RuntimeConfig) -> Result<IdentityInitView, RuntimeError> { +pub fn init(config: &RuntimeConfig) -> Result<AccountNewView, RuntimeError> { let identity = initialize_identity(&config.identity)?; - Ok(IdentityInitView { + Ok(AccountNewView { path: identity.path.display().to_string(), created: identity.created, public_identity: IdentityPublicView::from_public_identity(&identity.public_identity), @@ -18,7 +18,7 @@ pub fn init(config: &RuntimeConfig) -> Result<IdentityInitView, RuntimeError> { pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { let view = match load_identity(&config.identity) { - Ok(identity) => IdentityShowView { + Ok(identity) => AccountWhoamiView { path: identity.path.display().to_string(), state: "ready".to_owned(), reason: None, @@ -26,7 +26,7 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { &identity.public_identity, )), }, - Err(RuntimeError::Identity(IdentityError::NotFound(path))) => IdentityShowView { + Err(RuntimeError::Identity(IdentityError::NotFound(path))) => AccountWhoamiView { path: path.display().to_string(), state: "unconfigured".to_owned(), reason: Some(format!( @@ -39,15 +39,15 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { }; Ok(match view.disposition() { - CommandDisposition::Success => CommandOutput::success(CommandView::IdentityShow(view)), + CommandDisposition::Success => CommandOutput::success(CommandView::AccountWhoami(view)), CommandDisposition::Unconfigured => { - CommandOutput::unconfigured(CommandView::IdentityShow(view)) + CommandOutput::unconfigured(CommandView::AccountWhoami(view)) } CommandDisposition::ExternalUnavailable => { - CommandOutput::external_unavailable(CommandView::IdentityShow(view)) + CommandOutput::external_unavailable(CommandView::AccountWhoami(view)) } CommandDisposition::InternalError => { - CommandOutput::internal_error(CommandView::IdentityShow(view)) + CommandOutput::internal_error(CommandView::AccountWhoami(view)) } }) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -3,7 +3,10 @@ pub mod myc; pub mod runtime; pub mod signer; -use crate::cli::{Command, IdentityCommand, MycCommand, RuntimeCommand, SignerCommand}; +use crate::cli::{ + AccountCommand, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand, + NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand, +}; use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -15,22 +18,76 @@ pub fn dispatch( logging: &LoggingState, ) -> Result<CommandOutput, RuntimeError> { match command { - Command::Identity(identity) => match identity.command { - IdentityCommand::Init => Ok(CommandOutput::success(CommandView::IdentityInit( + Command::Account(account) => match &account.command { + AccountCommand::New => Ok(CommandOutput::success(CommandView::AccountNew( identity::init(config)?, ))), - IdentityCommand::Show => identity::show(config), + AccountCommand::Whoami => identity::show(config), + AccountCommand::Ls => unimplemented_command("account ls"), + AccountCommand::Use(_) => unimplemented_command("account use"), }, - Command::Myc(myc) => match myc.command { + Command::Myc(myc) => match &myc.command { MycCommand::Status => Ok(myc::status(config)), }, - Command::Runtime(runtime) => match runtime.command { - RuntimeCommand::Show => Ok(CommandOutput::success(CommandView::RuntimeShow( + Command::Config(config_command) => match &config_command.command { + ConfigCommand::Show => Ok(CommandOutput::success(CommandView::ConfigShow( runtime::show(config, logging), ))), }, - Command::Signer(signer) => match signer.command { + Command::Signer(signer) => match &signer.command { SignerCommand::Status => Ok(signer::status(config)), }, + Command::Doctor => unimplemented_command("doctor"), + Command::Find(_) => unimplemented_command("find"), + Command::Job(job) => match &job.command { + JobCommand::Ls => unimplemented_command("job ls"), + JobCommand::Get(_) => unimplemented_command("job get"), + JobCommand::Watch(_) => unimplemented_command("job watch"), + }, + Command::Listing(listing) => match &listing.command { + ListingCommand::New => unimplemented_command("listing new"), + ListingCommand::Validate => unimplemented_command("listing validate"), + ListingCommand::Get(_) => unimplemented_command("listing get"), + ListingCommand::Publish => unimplemented_command("listing publish"), + ListingCommand::Update(_) => unimplemented_command("listing update"), + ListingCommand::Archive(_) => unimplemented_command("listing archive"), + }, + Command::Local(local) => match &local.command { + LocalCommand::Init => unimplemented_command("local init"), + LocalCommand::Status => unimplemented_command("local status"), + LocalCommand::Export => unimplemented_command("local export"), + LocalCommand::Backup => unimplemented_command("local backup"), + }, + Command::Net(net) => match &net.command { + NetCommand::Status => unimplemented_command("net status"), + }, + Command::Order(order) => match &order.command { + OrderCommand::New => unimplemented_command("order new"), + OrderCommand::Get(_) => unimplemented_command("order get"), + OrderCommand::Ls => unimplemented_command("order ls"), + OrderCommand::Submit => unimplemented_command("order submit"), + OrderCommand::Watch(_) => unimplemented_command("order watch"), + OrderCommand::Cancel(_) => unimplemented_command("order cancel"), + OrderCommand::History => unimplemented_command("order history"), + }, + Command::Relay(relay) => match &relay.command { + RelayCommand::Ls => unimplemented_command("relay ls"), + }, + Command::Rpc(rpc) => match &rpc.command { + RpcCommand::Status => unimplemented_command("rpc status"), + RpcCommand::Sessions => unimplemented_command("rpc sessions"), + }, + Command::Sync(sync) => match &sync.command { + SyncCommand::Status => unimplemented_command("sync status"), + SyncCommand::Pull => unimplemented_command("sync pull"), + SyncCommand::Push => unimplemented_command("sync push"), + SyncCommand::Watch => unimplemented_command("sync watch"), + }, } } + +fn unimplemented_command(name: &str) -> Result<CommandOutput, RuntimeError> { + Err(RuntimeError::Config(format!( + "`{name}` is not implemented yet" + ))) +} diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,12 +1,18 @@ use crate::domain::runtime::{ - IdentityRuntimeView, LoggingRuntimeView, MycRuntimeView, RuntimeShowView, SignerRuntimeView, + AccountRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, PathsRuntimeView, + SignerRuntimeView, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; -pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> RuntimeShowView { - RuntimeShowView { +pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { + ConfigShowView { output_format: config.output_format.as_str().to_owned(), + paths: PathsRuntimeView { + user_config_path: config.paths.user_config_path.display().to_string(), + workspace_config_path: config.paths.workspace_config_path.display().to_string(), + user_state_root: config.paths.user_state_root.display().to_string(), + }, logging: LoggingRuntimeView { initialized: logging.initialized, filter: config.logging.filter.clone(), @@ -21,8 +27,8 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> RuntimeShowView { .as_ref() .map(|path| path.display().to_string()), }, - identity: IdentityRuntimeView { - path: config.identity.path.display().to_string(), + account: AccountRuntimeView { + identity_path: config.identity.path.display().to_string(), }, signer: SignerRuntimeView { backend: config.signer.backend.as_str().to_owned(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -67,18 +67,19 @@ impl CommandDisposition { #[derive(Debug, Clone)] pub enum CommandView { - IdentityInit(IdentityInitView), - IdentityShow(IdentityShowView), + AccountNew(AccountNewView), + AccountWhoami(AccountWhoamiView), + ConfigShow(ConfigShowView), MycStatus(MycStatusView), - RuntimeShow(RuntimeShowView), SignerStatus(SignerStatusView), } #[derive(Debug, Clone, Serialize)] -pub struct RuntimeShowView { +pub struct ConfigShowView { pub output_format: String, + pub paths: PathsRuntimeView, pub logging: LoggingRuntimeView, - pub identity: IdentityRuntimeView, + pub account: AccountRuntimeView, pub signer: SignerRuntimeView, pub myc: MycRuntimeView, } @@ -93,8 +94,15 @@ pub struct LoggingRuntimeView { } #[derive(Debug, Clone, Serialize)] -pub struct IdentityRuntimeView { - pub path: String, +pub struct PathsRuntimeView { + pub user_config_path: String, + pub workspace_config_path: String, + pub user_state_root: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AccountRuntimeView { + pub identity_path: String, } #[derive(Debug, Clone, Serialize)] @@ -125,7 +133,7 @@ impl IdentityPublicView { } #[derive(Debug, Clone, Serialize)] -pub struct IdentityShowView { +pub struct AccountWhoamiView { pub path: String, pub state: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -134,7 +142,7 @@ pub struct IdentityShowView { pub public_identity: Option<IdentityPublicView>, } -impl IdentityShowView { +impl AccountWhoamiView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "unconfigured" => CommandDisposition::Unconfigured, @@ -144,7 +152,7 @@ impl IdentityShowView { } #[derive(Debug, Clone, Serialize)] -pub struct IdentityInitView { +pub struct AccountNewView { pub path: String, pub created: bool, pub public_identity: IdentityPublicView, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -14,8 +14,8 @@ pub fn render_output(output: &CommandOutput, format: OutputFormat) -> Result<(), fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); match output.view() { - CommandView::IdentityInit(view) => { - writeln!(stdout, "identity init")?; + CommandView::AccountNew(view) => { + writeln!(stdout, "account new")?; writeln!(stdout, " path: {}", view.path)?; writeln!(stdout, " created: {}", yes_no(view.created))?; writeln!(stdout, " id: {}", view.public_identity.id)?; @@ -30,15 +30,13 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { view.public_identity.public_key_npub )?; } - CommandView::IdentityShow(view) => { - writeln!(stdout, "identity")?; + CommandView::AccountWhoami(view) => { + writeln!(stdout, "account")?; writeln!(stdout, " path: {}", view.path)?; writeln!(stdout, " state: {}", view.state)?; - writeln!( - stdout, - " reason: {}", - view.reason.as_deref().unwrap_or("<none>") - )?; + if let Some(reason) = &view.reason { + writeln!(stdout, " reason: {reason}")?; + } if let Some(public_identity) = &view.public_identity { writeln!(stdout, " id: {}", public_identity.id)?; writeln!( @@ -56,9 +54,17 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { CommandView::MycStatus(view) => { render_myc_status(&mut stdout, view)?; } - CommandView::RuntimeShow(view) => { - writeln!(stdout, "runtime")?; + CommandView::ConfigShow(view) => { + writeln!(stdout, "config")?; writeln!(stdout, " output format: {}", view.output_format)?; + writeln!(stdout, "paths")?; + writeln!(stdout, " user config: {}", view.paths.user_config_path)?; + writeln!( + stdout, + " workspace config: {}", + view.paths.workspace_config_path + )?; + writeln!(stdout, " user state root: {}", view.paths.user_state_root)?; writeln!(stdout, "logging")?; writeln!( stdout, @@ -67,18 +73,14 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { )?; writeln!(stdout, " filter: {}", view.logging.filter)?; writeln!(stdout, " stdout: {}", yes_no(view.logging.stdout))?; - writeln!( - stdout, - " directory: {}", - view.logging.directory.as_deref().unwrap_or("<disabled>") - )?; - writeln!( - stdout, - " current file: {}", - view.logging.current_file.as_deref().unwrap_or("<disabled>") - )?; - writeln!(stdout, "identity")?; - writeln!(stdout, " path: {}", view.identity.path)?; + if let Some(directory) = &view.logging.directory { + writeln!(stdout, " directory: {directory}")?; + } + if let Some(current_file) = &view.logging.current_file { + writeln!(stdout, " current file: {current_file}")?; + } + writeln!(stdout, "account")?; + writeln!(stdout, " identity path: {}", view.account.identity_path)?; writeln!(stdout, "signer")?; writeln!(stdout, " backend: {}", view.signer.backend)?; writeln!(stdout, "myc")?; @@ -107,11 +109,11 @@ fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> { fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { let mut stdout = io::stdout().lock(); match output.view() { - CommandView::IdentityInit(view) => { + CommandView::AccountNew(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandView::IdentityShow(view) => { + CommandView::AccountWhoami(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } @@ -119,7 +121,7 @@ fn render_json(output: &CommandOutput) -> Result<(), RuntimeError> { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } - CommandView::RuntimeShow(view) => { + CommandView::ConfigShow(view) => { serde_json::to_writer_pretty(&mut stdout, view)?; writeln!(stdout)?; } @@ -233,16 +235,21 @@ fn render_myc_custody_identity( mod tests { use crate::commands::runtime; use crate::runtime::config::{ - IdentityConfig, LoggingConfig, MycConfig, OutputFormat, RuntimeConfig, SignerBackend, - SignerConfig, + IdentityConfig, LoggingConfig, MycConfig, OutputFormat, PathsConfig, RuntimeConfig, + SignerBackend, SignerConfig, }; use crate::runtime::logging::LoggingState; #[test] - fn human_render_contains_runtime_sections() { + fn human_render_contains_config_sections() { let view = runtime::show( &RuntimeConfig { output_format: OutputFormat::Human, + paths: PathsConfig { + user_config_path: "/home/tester/.config/radroots/config.toml".into(), + workspace_config_path: "/workspace/.radroots/config.toml".into(), + user_state_root: "/home/tester/.local/share/radroots".into(), + }, logging: LoggingConfig { filter: "info".to_owned(), directory: None, @@ -263,16 +270,11 @@ mod tests { current_file: None, }, ); - let rendered = format!( - "runtime\n output format: {}\nlogging\n initialized: {}\n", - view.output_format, - if view.logging.initialized { - "yes" - } else { - "no" - } + assert_eq!(view.output_format, "human"); + assert_eq!( + view.paths.workspace_config_path, + "/workspace/.radroots/config.toml" ); - assert!(rendered.contains("runtime")); - assert!(rendered.contains("logging")); + assert_eq!(view.account.identity_path, "identity.json"); } } diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -8,6 +8,9 @@ use crate::runtime::RuntimeError; const DEFAULT_LOG_FILTER: &str = "info"; const DEFAULT_ENV_PATH: &str = ".env"; +const DEFAULT_WORKSPACE_CONFIG_PATH: &str = ".radroots/config.toml"; +const DEFAULT_USER_CONFIG_PATH: &str = ".config/radroots/config.toml"; +const DEFAULT_USER_STATE_ROOT: &str = ".local/share/radroots"; const ENV_FILE_PATH: &str = "RADROOTS_ENV_FILE"; const ENV_OUTPUT: &str = "RADROOTS_OUTPUT"; const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; @@ -87,17 +90,27 @@ pub struct MycConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { pub output_format: OutputFormat, + pub paths: PathsConfig, pub logging: LoggingConfig, pub identity: IdentityConfig, pub signer: SignerConfig, pub myc: MycConfig, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathsConfig { + pub user_config_path: PathBuf, + pub workspace_config_path: PathBuf, + pub user_state_root: PathBuf, +} + #[derive(Debug, Default)] struct EnvFileValues(BTreeMap<String, String>); pub trait Environment { fn var(&self, key: &str) -> Option<String>; + fn current_dir(&self) -> Result<PathBuf, RuntimeError>; + fn home_dir(&self) -> Option<PathBuf>; } pub struct SystemEnvironment; @@ -106,6 +119,16 @@ impl Environment for SystemEnvironment { fn var(&self, key: &str) -> Option<String> { std::env::var(key).ok() } + + fn current_dir(&self) -> Result<PathBuf, RuntimeError> { + std::env::current_dir().map_err(|err| { + RuntimeError::Config(format!("failed to resolve current directory: {err}")) + }) + } + + fn home_dir(&self) -> Option<PathBuf> { + std::env::var_os("HOME").map(PathBuf::from) + } } impl RuntimeConfig { @@ -123,6 +146,7 @@ impl RuntimeConfig { ) -> Result<Self, RuntimeError> { Ok(Self { output_format: resolve_output_format(args, env, env_file)?, + paths: resolve_paths(env)?, logging: LoggingConfig { filter: args .log_filter @@ -170,6 +194,21 @@ impl RuntimeConfig { } } +fn resolve_paths(env: &dyn Environment) -> Result<PathsConfig, RuntimeError> { + let current_dir = env.current_dir()?; + let home_dir = env.home_dir().ok_or_else(|| { + RuntimeError::Config( + "failed to resolve home directory for Radroots config roots".to_owned(), + ) + })?; + + Ok(PathsConfig { + user_config_path: home_dir.join(DEFAULT_USER_CONFIG_PATH), + workspace_config_path: current_dir.join(DEFAULT_WORKSPACE_CONFIG_PATH), + user_state_root: home_dir.join(DEFAULT_USER_STATE_ROOT), + }) +} + fn resolve_env_file_path(args: &CliArgs, env: &dyn Environment) -> Option<PathBuf> { args.env_file .clone() @@ -329,7 +368,7 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { #[cfg(test)] mod tests { use super::{ - EnvFileValues, Environment, OutputFormat, RuntimeConfig, SignerBackend, + EnvFileValues, Environment, OutputFormat, PathsConfig, RuntimeConfig, SignerBackend, parse_env_file_values, }; use crate::cli::CliArgs; @@ -337,11 +376,33 @@ mod tests { use std::collections::BTreeMap; use std::path::{Path, PathBuf}; - struct MapEnvironment(BTreeMap<String, String>); + struct MapEnvironment { + values: BTreeMap<String, String>, + current_dir: PathBuf, + home_dir: PathBuf, + } + + impl MapEnvironment { + fn new(values: BTreeMap<String, String>) -> Self { + Self { + values, + current_dir: PathBuf::from("/workspaces/radroots-cli"), + home_dir: PathBuf::from("/home/tester"), + } + } + } impl Environment for MapEnvironment { fn var(&self, key: &str) -> Option<String> { - self.0.get(key).cloned() + self.values.get(key).cloned() + } + + fn current_dir(&self) -> Result<PathBuf, crate::runtime::RuntimeError> { + Ok(self.current_dir.clone()) + } + + fn home_dir(&self) -> Option<PathBuf> { + Some(self.home_dir.clone()) } } @@ -359,10 +420,10 @@ mod tests { "local", "--myc-executable", "bin/myc-cli", - "runtime", + "config", "show", ]); - let env = MapEnvironment(BTreeMap::from([ + let env = MapEnvironment::new(BTreeMap::from([ ("RADROOTS_OUTPUT".to_owned(), "human".to_owned()), ("RADROOTS_LOG_FILTER".to_owned(), "trace".to_owned()), ("RADROOTS_LOG_STDOUT".to_owned(), "false".to_owned()), @@ -377,6 +438,16 @@ mod tests { let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect("resolve runtime config"); assert_eq!(resolved.output_format, OutputFormat::Json); + assert_eq!( + resolved.paths, + PathsConfig { + user_config_path: PathBuf::from("/home/tester/.config/radroots/config.toml"), + workspace_config_path: PathBuf::from( + "/workspaces/radroots-cli/.radroots/config.toml" + ), + user_state_root: PathBuf::from("/home/tester/.local/share/radroots"), + } + ); assert_eq!(resolved.logging.filter, "debug"); assert!(resolved.logging.stdout); assert_eq!( @@ -389,8 +460,8 @@ mod tests { #[test] fn environment_values_fill_missing_flags() { - let args = CliArgs::parse_from(["radroots", "runtime", "show"]); - let env = MapEnvironment(BTreeMap::from([ + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([ ("RADROOTS_OUTPUT".to_owned(), "json".to_owned()), ( "RADROOTS_LOG_FILTER".to_owned(), @@ -426,10 +497,10 @@ mod tests { "radroots", "--log-stdout", "--no-log-stdout", - "runtime", + "config", "show", ]); - let env = MapEnvironment(BTreeMap::new()); + let env = MapEnvironment::new(BTreeMap::new()); let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect_err("conflicting flags"); assert!(error.to_string().contains("cannot be used together")); @@ -437,8 +508,8 @@ mod tests { #[test] fn invalid_environment_value_fails() { - let args = CliArgs::parse_from(["radroots", "runtime", "show"]); - let env = MapEnvironment(BTreeMap::from([( + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([( "RADROOTS_LOG_STDOUT".to_owned(), "maybe".to_owned(), )])); @@ -449,8 +520,8 @@ mod tests { #[test] fn env_file_values_fill_missing_flags() { - let args = CliArgs::parse_from(["radroots", "runtime", "show"]); - let env = MapEnvironment(BTreeMap::new()); + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::new()); let env_file = parse_env_file_values( r#" RADROOTS_OUTPUT=json @@ -481,8 +552,8 @@ RADROOTS_MYC_EXECUTABLE=bin/myc #[test] fn process_environment_overrides_env_file_values() { - let args = CliArgs::parse_from(["radroots", "runtime", "show"]); - let env = MapEnvironment(BTreeMap::from([ + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::from([ ("RADROOTS_LOG_FILTER".to_owned(), "info".to_owned()), ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), ])); @@ -502,6 +573,27 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn state_roots_are_resolved_from_home_and_workspace() { + let args = CliArgs::parse_from(["radroots", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::new()); + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + + assert_eq!( + resolved.paths.user_config_path, + PathBuf::from("/home/tester/.config/radroots/config.toml") + ); + assert_eq!( + resolved.paths.workspace_config_path, + PathBuf::from("/workspaces/radroots-cli/.radroots/config.toml") + ); + assert_eq!( + resolved.paths.user_state_root, + PathBuf::from("/home/tester/.local/share/radroots") + ); + } + + #[test] fn unknown_env_file_variable_fails() { let error = parse_env_file_values( "RADROOTS_CLI_LOGGING_FILTRE=debug\n", diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -27,7 +27,7 @@ fn cli_command_in(workdir: &Path) -> Command { } #[test] -fn identity_init_json_creates_identity_file() { +fn account_new_json_creates_identity_file() { let dir = tempdir().expect("tempdir"); let identity_path = dir.path().join("identity.json"); @@ -36,11 +36,11 @@ fn identity_init_json_creates_identity_file() { "--json", "--identity-path", identity_path.to_str().expect("identity path"), - "identity", - "init", + "account", + "new", ]) .output() - .expect("run identity init"); + .expect("run account new"); assert!(output.status.success()); assert!(identity_path.exists()); @@ -56,7 +56,7 @@ fn identity_init_json_creates_identity_file() { } #[test] -fn identity_show_json_reads_existing_public_identity() { +fn account_whoami_json_reads_existing_public_identity() { let dir = tempdir().expect("tempdir"); let identity_path = dir.path().join("identity.json"); @@ -65,11 +65,11 @@ fn identity_show_json_reads_existing_public_identity() { "--json", "--identity-path", identity_path.to_str().expect("identity path"), - "identity", - "init", + "account", + "new", ]) .output() - .expect("run identity init"); + .expect("run account new"); assert!(init.status.success()); let output = cli_command_in(dir.path()) @@ -77,11 +77,11 @@ fn identity_show_json_reads_existing_public_identity() { "--json", "--identity-path", identity_path.to_str().expect("identity path"), - "identity", - "show", + "account", + "whoami", ]) .output() - .expect("run identity show"); + .expect("run account whoami"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); @@ -95,7 +95,7 @@ fn identity_show_json_reads_existing_public_identity() { } #[test] -fn identity_show_json_reports_unconfigured_without_creating_identity() { +fn account_whoami_json_reports_unconfigured_without_creating_identity() { let dir = tempdir().expect("tempdir"); let identity_path = dir.path().join("missing-identity.json"); @@ -104,11 +104,11 @@ fn identity_show_json_reports_unconfigured_without_creating_identity() { "--json", "--identity-path", identity_path.to_str().expect("identity path"), - "identity", - "show", + "account", + "whoami", ]) .output() - .expect("run identity show"); + .expect("run account whoami"); assert_eq!(output.status.code(), Some(3)); assert!(!identity_path.exists()); diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -9,6 +9,7 @@ use tempfile::tempdir; fn runtime_show_command_in(workdir: &Path) -> Command { let mut command = Command::cargo_bin("radroots").expect("binary"); command.current_dir(workdir); + command.env("HOME", workdir.join("home")); for key in [ "RADROOTS_ENV_FILE", "RADROOTS_OUTPUT", @@ -28,27 +29,51 @@ fn runtime_show_command_in(workdir: &Path) -> Command { } #[test] -fn runtime_show_json_reports_default_bootstrap_state() { +fn config_show_json_reports_default_bootstrap_state() { let dir = tempdir().expect("tempdir"); + let canonical_root = dir.path().canonicalize().expect("canonical tempdir"); let output = runtime_show_command_in(dir.path()) - .args(["--json", "runtime", "show"]) + .args(["--json", "config", "show"]) .output() - .expect("run runtime show"); + .expect("run config show"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["output_format"], "json"); + assert_eq!( + json["paths"]["user_config_path"], + dir.path() + .join("home") + .join(".config/radroots/config.toml") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["workspace_config_path"], + canonical_root + .join(".radroots/config.toml") + .display() + .to_string() + ); + assert_eq!( + json["paths"]["user_state_root"], + dir.path() + .join("home") + .join(".local/share/radroots") + .display() + .to_string() + ); assert_eq!(json["logging"]["initialized"], true); assert_eq!(json["logging"]["stdout"], false); assert_eq!(json["logging"]["directory"], Value::Null); - assert_eq!(json["identity"]["path"], "identity.json"); + assert_eq!(json["account"]["identity_path"], "identity.json"); assert_eq!(json["signer"]["backend"], "local"); assert_eq!(json["myc"]["executable"], "myc"); } #[test] -fn runtime_show_json_reflects_environment_configuration() { +fn config_show_json_reflects_environment_configuration() { let dir = tempdir().expect("tempdir"); let output = runtime_show_command_in(dir.path()) .env("RADROOTS_OUTPUT", "json") @@ -58,22 +83,22 @@ fn runtime_show_json_reflects_environment_configuration() { .env("RADROOTS_IDENTITY_PATH", "state/identity.json") .env("RADROOTS_SIGNER_BACKEND", "myc") .env("RADROOTS_MYC_EXECUTABLE", "bin/myc") - .args(["runtime", "show"]) + .args(["config", "show"]) .output() - .expect("run runtime show"); + .expect("run config show"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); assert_eq!(json["logging"]["filter"], "debug"); assert_eq!(json["logging"]["directory"], "logs/runtime"); - assert_eq!(json["identity"]["path"], "state/identity.json"); + assert_eq!(json["account"]["identity_path"], "state/identity.json"); assert_eq!(json["signer"]["backend"], "myc"); assert_eq!(json["myc"]["executable"], "bin/myc"); } #[test] -fn runtime_show_json_reads_logging_from_default_env_file() { +fn config_show_json_reads_logging_from_default_env_file() { let temp = tempdir().expect("tempdir"); let env_path = temp.path().join(".env"); let logs_dir = temp.path().join("logs").join("radroots-cli"); @@ -87,9 +112,9 @@ fn runtime_show_json_reads_logging_from_default_env_file() { .expect("write env file"); let output = runtime_show_command_in(temp.path()) - .args(["--json", "runtime", "show"]) + .args(["--json", "config", "show"]) .output() - .expect("run runtime show"); + .expect("run config show"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -37,11 +37,11 @@ fn signer_status_reports_local_ready_when_identity_exists() { "--json", "--identity-path", identity_path.to_str().expect("identity path"), - "identity", - "init", + "account", + "new", ]) .output() - .expect("run identity init"); + .expect("run account new"); assert!(init.status.success()); let output = cli_command_in(dir.path())