Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ jobs:

gh release create "$TAG" \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "Atomic ${TAG}" \
--notes-file release-notes.md \
$PRERELEASE_FLAG \
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ members = [
]

[workspace.package]
version = "0.5.5"
version = "0.6.0"
edition = "2021"
authors = ["Atomic Contributors"]
license = "Apache-2.0"
Expand Down
139 changes: 128 additions & 11 deletions atomic-cli/src/commands/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,26 @@ use crate::error::{CliError, CliResult};
/// - Identity store cannot be opened or no default identity set.
/// - HTTP client construction failure.
pub fn build_client(org_override: Option<&str>) -> CliResult<StorageClient> {
let (client, _org_slug) = build_client_with_org(org_override)?;
Ok(client)
}

/// Build a [`StorageClient`] and return the resolved org slug alongside it.
///
/// Useful for commands that also need to resolve org-scoped state (e.g.
/// per-org default workspace lookup). Avoids resolving the org twice.
pub fn build_client_with_org(org_override: Option<&str>) -> CliResult<(StorageClient, String)> {
let config = GlobalConfig::load()
.map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load global config: {}", e)))?;

let server = &config.server;
if !server.is_configured() {
if server.url.is_none() {
return Err(CliError::Internal(anyhow::anyhow!(
"Server not configured. Run 'atomic identity register <server-url>' first."
)));
}

let org_slug = org_override
.map(|s| s.to_string())
.or_else(|| server.default_org.clone())
.ok_or_else(|| {
CliError::Internal(anyhow::anyhow!(
"No organization specified. Use --org or set a default with 'atomic org switch'."
))
})?;
let org_slug = resolve_org(org_override)?;

let base_url = server.org_base_url(&org_slug).ok_or_else(|| {
CliError::Internal(anyhow::anyhow!(
Expand All @@ -86,8 +88,11 @@ pub fn build_client(org_override: Option<&str>) -> CliResult<StorageClient> {

let bearer_token = identity.public_key_base32();

StorageClient::new(&base_url, &org_slug, &bearer_token)
.map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e)))
let client = StorageClient::new(&base_url, &org_slug, &bearer_token).map_err(|e| {
CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e))
})?;

Ok((client, org_slug))
}

/// Convenience: map a [`atomic_remote::RemoteError`] to a [`CliError`].
Expand All @@ -98,6 +103,80 @@ pub fn remote_err(e: atomic_remote::RemoteError) -> CliError {
}
}

/// Resolve the org slug for a command, with fallback to the configured default.
///
/// Resolution order:
/// 1. `--org` override (if `Some(non-empty)`)
/// 2. `server.default_org` from global config
/// 3. Error with a hint to run `atomic org set`
///
/// An explicit empty string (`--org ""`) is an error: the user asked for "no
/// org" which never makes sense.
pub fn resolve_org(org_override: Option<&str>) -> CliResult<String> {
if let Some(s) = org_override {
if s.is_empty() {
return Err(CliError::InvalidArgument {
message: "Organization slug cannot be empty.".to_string(),
});
}
return Ok(s.to_string());
}

let config = GlobalConfig::load()
.map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load global config: {}", e)))?;

config
.server
.default_org
.ok_or_else(|| CliError::InvalidArgument {
message: "No organization specified.\n \
Use --org or set a default with: atomic org set <slug>"
.to_string(),
})
}

/// Resolve the workspace slug for a command, with fallback to the
/// org-scoped default workspace from global config.
///
/// Workspaces are org-scoped, so the lookup is keyed by `org_slug`. The
/// caller is responsible for resolving the org first (typically with
/// [`resolve_org`]).
///
/// Resolution order:
/// 1. `--workspace` override (if `Some(non-empty)`)
/// 2. `server.default_workspaces[org_slug]` from global config
/// 3. Error with a hint to run `atomic workspace set`
///
/// An explicit empty string (`--workspace ""`) is an error: the user
/// explicitly asked for "no workspace", which is meaningless. Distinguishing
/// `None` (not provided → fall back to default) from `Some("")` (provided
/// empty → error) prevents a class of confusing bugs.
pub fn resolve_workspace(org_slug: &str, workspace_override: Option<&str>) -> CliResult<String> {
if let Some(s) = workspace_override {
if s.is_empty() {
return Err(CliError::InvalidArgument {
message: "Workspace slug cannot be empty.".to_string(),
});
}
return Ok(s.to_string());
}

let config = GlobalConfig::load()
.map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load global config: {}", e)))?;

config
.server
.default_workspaces
.get(org_slug)
.cloned()
.ok_or_else(|| CliError::InvalidArgument {
message: format!(
"No workspace specified for org '{org_slug}'.\n \
Use --workspace or set a default with: atomic workspace set <slug>"
),
})
}

/// Resolve a flexible identity reference to a UUID.
///
/// Accepts:
Expand Down Expand Up @@ -210,4 +289,42 @@ mod tests {
assert!("alice@example.com".contains('@'));
assert!(!"alice".contains('@'));
}

// -- resolve_org / resolve_workspace (override-branch only;
// the config-fallback branch touches disk and is exercised by
// integration tests). --

#[test]
fn resolve_org_passes_through_explicit_override() {
let result = resolve_org(Some("acme")).unwrap();
assert_eq!(result, "acme");
}

#[test]
fn resolve_org_rejects_explicit_empty_override() {
let err = resolve_org(Some("")).unwrap_err();
match err {
CliError::InvalidArgument { message } => {
assert!(message.contains("cannot be empty"));
}
other => panic!("expected InvalidArgument, got {:?}", other),
}
}

#[test]
fn resolve_workspace_passes_through_explicit_override() {
let result = resolve_workspace("acme", Some("backend")).unwrap();
assert_eq!(result, "backend");
}

#[test]
fn resolve_workspace_rejects_explicit_empty_override() {
let err = resolve_workspace("acme", Some("")).unwrap_err();
match err {
CliError::InvalidArgument { message } => {
assert!(message.contains("cannot be empty"));
}
other => panic!("expected InvalidArgument, got {:?}", other),
}
}
}
2 changes: 1 addition & 1 deletion atomic-cli/src/commands/diff/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ impl Diff {
color: !self.no_color,
format: self.get_format(),
stat_width: 80,
show_line_numbers: true,
show_line_numbers: false,
show_path_prefix: true,
word_diff: self.word_diff,
}
Expand Down
Loading