fabro/lib/crates/fabro-cli/src/command_context.rs
Bryan Helmkamp 3f9a861f87
fix(error): preserve source chains across workspace
Keep typed transport and provider errors intact through API, GitHub, OAuth, install, diagnostics, and artifact paths. Add regression coverage for cloned shared errors and communication error chains.
2026-05-01 17:30:20 -04:00

391 lines
13 KiB
Rust

use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
use fabro_config::{CliLayer, Storage};
use fabro_types::UserSettings;
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_util::error::SharedError;
use fabro_util::printer::Printer;
use fabro_vault::Vault;
use tokio::sync::{OnceCell, RwLock as AsyncRwLock};
use crate::args::{
ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override,
};
use crate::server_client::Client;
use crate::user_config::LoadedSettings;
use crate::{server_client, user_config};
#[derive(Clone, Debug)]
pub(crate) enum ServerMode {
None,
ByTarget {
target_override: Option<String>,
},
ByStorageDir {
target_override: Option<String>,
storage_dir_override: Option<PathBuf>,
},
}
pub(crate) struct CommandContext {
printer: Printer,
process_local_json: bool,
cwd: PathBuf,
base_config_path: PathBuf,
cli_layer: CliLayer,
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
server_mode: ServerMode,
server: OnceCell<Arc<Client>>,
llm_source: OnceCell<Arc<dyn CredentialSource>>,
}
struct ResolvedCommandSettings {
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, SharedError>,
user_settings: UserSettings,
}
impl CommandContext {
pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result<Self> {
let resolved_settings = load_merged_settings(cli_layer, &ServerMode::None)?;
let printer = printer_from_verbosity(resolved_settings.user_settings.cli.output.verbosity);
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let base_config_path = user_config::active_settings_path(None);
Ok(Self {
printer,
process_local_json,
cwd,
base_config_path,
cli_layer: cli_layer.clone(),
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),
})
}
pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result<Self> {
self.with_server_mode(ServerMode::ByTarget {
target_override: args.server.clone(),
})
}
pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result<Self> {
self.with_server_mode(ServerMode::ByStorageDir {
target_override: args.target.server.clone(),
storage_dir_override: args.storage_dir.clone_path(),
})
}
pub(crate) fn printer(&self) -> Printer {
self.printer
}
pub(crate) fn explicit_json_requested(&self) -> bool {
self.process_local_json
}
pub(crate) fn require_no_json_override(&self) -> Result<()> {
require_no_json_override(self.process_local_json)
}
pub(crate) fn cwd(&self) -> &Path {
&self.cwd
}
pub(crate) fn run_settings(&self) -> Result<&RunNamespace> {
self.run_settings
.as_ref()
.map_err(|err| anyhow::Error::new(err.clone()))
}
pub(crate) fn user_settings(&self) -> &UserSettings {
&self.user_settings
}
pub(crate) fn base_config_path(&self) -> &Path {
&self.base_config_path
}
pub(crate) fn json_output(&self) -> bool {
self.user_settings.cli.output.format == OutputFormat::Json
}
pub(crate) fn verbose(&self) -> bool {
self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose
}
pub(crate) async fn server(&self) -> Result<Arc<Client>> {
let server_mode = self.server_mode.clone();
let base_config_path = self.base_config_path.clone();
let storage_dir = self.storage_dir.clone();
let user_settings = self.user_settings.clone();
let client = self
.server
.get_or_try_init(|| async move {
let target = match server_mode {
ServerMode::None => bail!("This command context does not have server access"),
ServerMode::ByTarget { target_override }
| ServerMode::ByStorageDir {
target_override, ..
} => ServerTargetArgs {
server: target_override,
},
};
server_client::connect_server_with_settings(
&target,
&user_settings,
&storage_dir,
&base_config_path,
)
.await
.map(Arc::new)
})
.await?;
Ok(Arc::clone(client))
}
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialSource>> {
let storage_dir = self.storage_dir.clone();
let source = self
.llm_source
.get_or_try_init(|| async move {
let source: Arc<dyn CredentialSource> =
match Vault::load(Storage::new(&storage_dir).secrets_path()) {
Ok(vault) => Arc::new(VaultCredentialSource::new(Arc::new(
AsyncRwLock::new(vault),
))),
Err(_) => Arc::new(EnvCredentialSource::new()),
};
Ok::<Arc<dyn CredentialSource>, anyhow::Error>(source)
})
.await?;
Ok(Arc::clone(source))
}
fn with_server_mode(&self, server_mode: ServerMode) -> Result<Self> {
// Always reload settings for the requested derivation mode so the result
// depends only on the requested mode, not on whichever derived context
// happened to call into this helper.
let resolved_settings = load_merged_settings(&self.cli_layer, &server_mode)?;
Ok(Self {
printer: self.printer,
process_local_json: self.process_local_json,
cwd: self.cwd.clone(),
base_config_path: self.base_config_path.clone(),
cli_layer: self.cli_layer.clone(),
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
server_mode,
server: OnceCell::new(),
llm_source: OnceCell::new(),
})
}
}
fn load_merged_settings(
cli_layer: &CliLayer,
server_mode: &ServerMode,
) -> Result<ResolvedCommandSettings> {
let loaded_settings = match server_mode {
ServerMode::None | ServerMode::ByTarget { .. } => {
user_config::load_resolved_settings(None, None, Some(cli_layer))?
}
ServerMode::ByStorageDir {
storage_dir_override,
..
} => user_config::load_resolved_settings(
None,
storage_dir_override.as_deref(),
Some(cli_layer),
)?,
};
Ok(resolve_command_settings(loaded_settings))
}
fn resolve_command_settings(loaded_settings: LoadedSettings) -> ResolvedCommandSettings {
ResolvedCommandSettings {
storage_dir: loaded_settings.storage_dir,
run_settings: loaded_settings.run_settings,
user_settings: loaded_settings.user_settings,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fabro_config::{CliLayer, CliOutputLayer};
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_util::printer::Printer;
use tokio::sync::OnceCell;
use super::{CommandContext, ServerMode, resolve_command_settings};
use crate::user_config;
fn cli_layer_with_json_and_verbose() -> CliLayer {
CliLayer {
output: Some(CliOutputLayer {
format: Some(OutputFormat::Json),
verbosity: Some(OutputVerbosity::Verbose),
}),
..CliLayer::default()
}
}
fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext {
let cli_layer = cli_layer_with_json_and_verbose();
let resolved_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer))
.expect("settings should resolve"),
);
CommandContext {
printer,
process_local_json,
cwd: PathBuf::from("/tmp/workspace"),
base_config_path: PathBuf::from("/tmp/settings.toml"),
cli_layer,
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
user_settings: resolved_settings.user_settings,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),
}
}
#[test]
fn context_exposes_resolved_output_and_explicit_json_state() {
let ctx = synthetic_context(true, Printer::Default);
assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json);
assert_eq!(
ctx.user_settings().cli.output.verbosity,
OutputVerbosity::Verbose
);
assert!(ctx.explicit_json_requested());
assert_eq!(ctx.printer(), Printer::Default);
}
#[test]
fn storage_dir_override_only_changes_storage_root_in_merged_settings() {
let cli_layer = cli_layer_with_json_and_verbose();
let base_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro/default"
"#,
None,
Some(&cli_layer),
)
.expect("base settings should resolve"),
);
let connection_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro/default"
"#,
Some(std::path::Path::new("/srv/fabro/override")),
Some(&cli_layer),
)
.expect("connection settings should resolve"),
);
assert_eq!(
base_settings.user_settings,
connection_settings.user_settings
);
assert_eq!(
base_settings.user_settings.cli.output.format,
OutputFormat::Json
);
assert_eq!(
base_settings.storage_dir,
PathBuf::from("/srv/fabro/default")
);
assert_eq!(
connection_settings.storage_dir,
PathBuf::from("/srv/fabro/override")
);
assert_eq!(base_settings.run_settings.unwrap().agent.mcps.len(), 0);
assert_eq!(
connection_settings.run_settings.unwrap().agent.mcps.len(),
0
);
}
#[test]
fn storage_dir_stays_available_when_server_settings_do_not_resolve() {
let loaded = user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
None,
Some(&CliLayer::default()),
)
.expect("settings should resolve");
assert!(
loaded.server_settings.is_err(),
"fixture intentionally omits [server.auth] so server_settings should fail to resolve"
);
let resolved = resolve_command_settings(loaded);
assert_eq!(resolved.storage_dir, PathBuf::from("/srv/fabro"));
assert!(resolved.run_settings.is_ok());
}
#[test]
fn run_settings_include_run_agent_mcps() {
let resolved = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[run.agent.mcps.demo]
type = "stdio"
command = ["demo-mcp"]
"#,
None,
Some(&CliLayer::default()),
)
.expect("settings should resolve"),
);
let run_settings = resolved.run_settings.expect("run settings should resolve");
assert!(run_settings.agent.mcps.contains_key("demo"));
}
#[test]
fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() {
let json_ctx = synthetic_context(true, Printer::Default);
let text_ctx = synthetic_context(false, Printer::Default);
assert!(json_ctx.require_no_json_override().is_err());
assert!(text_ctx.require_no_json_override().is_ok());
}
}