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 src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod client;
pub mod generated;
pub mod projects;
pub mod session;
pub mod top_level;
231 changes: 231 additions & 0 deletions src/api/projects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// ABOUTME: Steel API /projects bindings and current-environment resolution.
// ABOUTME: Reports which project an API key is scoped to and whether it is production.

use serde::Deserialize;
use serde_json::Value;

use crate::api::client::{ApiError, SteelClient};
use crate::config::auth::Auth;
use crate::config::settings::ApiMode;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Project {
pub id: String,
pub name: String,
pub slug: String,
#[serde(default)]
pub is_production: bool,
#[serde(default)]
pub is_default: bool,
}

/// Parse the `/projects` response body into a list of projects.
/// Returns an empty list when the body has no parsable `projects` array.
pub fn parse_projects(data: &Value) -> Vec<Project> {
data.get("projects")
.cloned()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default()
}

/// Pick the project an API key is scoped to.
/// A single project wins outright; among several, a sole default wins.
pub fn resolve_current_project(projects: &[Project]) -> Option<&Project> {
if let [only] = projects {
return Some(only);
}
let mut defaults = projects.iter().filter(|p| p.is_default);
match (defaults.next(), defaults.next()) {
(Some(default), None) => Some(default),
_ => None,
}
}

/// Human label for a project's production flag.
pub const fn environment_label(is_production: bool) -> &'static str {
if is_production {
"production"
} else {
"development"
}
}

impl SteelClient {
/// Fetch the projects visible to the current API key.
pub async fn get_projects(
&self,
base_url: &str,
mode: ApiMode,
auth: &Auth,
) -> Result<Value, ApiError> {
self.request(
base_url,
mode,
reqwest::Method::GET,
"/projects",
None,
auth,
)
.await
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn project(name: &str, is_production: bool, is_default: bool) -> Project {
Project {
id: format!("id-{name}"),
name: name.into(),
slug: name.to_lowercase(),
is_production,
is_default,
}
}

// --- parse_projects ---

#[test]
fn parse_full_body() {
let data = json!({
"projects": [{
"id": "b4b8ce24-70cc-45aa-9233-c5e01fe24168",
"name": "Default project",
"slug": "default",
"isProduction": false,
"isDefault": true,
"promotedToProductionAt": null,
"createdAt": "2026-06-07T12:14:56.913Z",
"updatedAt": "2026-06-07T12:14:56.913Z"
}]
});

let projects = parse_projects(&data);
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "Default project");
assert_eq!(projects[0].slug, "default");
assert!(!projects[0].is_production);
assert!(projects[0].is_default);
}

#[test]
fn parse_empty_list() {
let data = json!({"projects": []});
assert!(parse_projects(&data).is_empty());
}

#[test]
fn parse_missing_key() {
let data = json!({"sessions": []});
assert!(parse_projects(&data).is_empty());
}

#[test]
fn parse_malformed_entries() {
let data = json!({"projects": [{"id": 42}]});
assert!(parse_projects(&data).is_empty());
}

#[test]
fn parse_missing_flags_default_false() {
let data = json!({
"projects": [{"id": "x", "name": "Bare", "slug": "bare"}]
});

let projects = parse_projects(&data);
assert_eq!(projects.len(), 1);
assert!(!projects[0].is_production);
assert!(!projects[0].is_default);
}

// --- resolve_current_project ---

#[test]
fn resolve_single_project() {
let projects = vec![project("Solo", true, false)];
assert_eq!(resolve_current_project(&projects).unwrap().name, "Solo");
}

#[test]
fn resolve_prefers_sole_default() {
let projects = vec![
project("Staging", false, false),
project("Main", true, true),
];
assert_eq!(resolve_current_project(&projects).unwrap().name, "Main");
}

#[test]
fn resolve_ambiguous_returns_none() {
let projects = vec![project("A", false, false), project("B", true, false)];
assert!(resolve_current_project(&projects).is_none());
}

#[test]
fn resolve_two_defaults_returns_none() {
let projects = vec![project("A", false, true), project("B", true, true)];
assert!(resolve_current_project(&projects).is_none());
}

#[test]
fn resolve_empty_returns_none() {
assert!(resolve_current_project(&[]).is_none());
}

// --- environment_label ---

#[test]
fn label_production() {
assert_eq!(environment_label(true), "production");
}

#[test]
fn label_development() {
assert_eq!(environment_label(false), "development");
}

// --- get_projects against a mock server ---

use crate::config::auth::AuthSource;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn get_projects_success() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/projects"))
.and(header("Steel-Api-Key", "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"projects": [{
"id": "p1",
"name": "Default project",
"slug": "default",
"isProduction": false,
"isDefault": true
}]
})))
.mount(&server)
.await;

let client = SteelClient::new().unwrap();
let auth = Auth {
api_key: Some("test-key".into()),
source: AuthSource::Env,
};

let data = client
.get_projects(&server.uri(), ApiMode::Cloud, &auth)
.await
.unwrap();

let projects = parse_projects(&data);
let current = resolve_current_project(&projects).unwrap();
assert_eq!(current.slug, "default");
assert_eq!(environment_label(current.is_production), "development");
}
}
34 changes: 32 additions & 2 deletions src/commands/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use std::time::Duration;

use clap::Parser;

use crate::config::auth;
use crate::config::settings::read_config;
use crate::api::client::SteelClient;
use crate::api::projects::{environment_label, parse_projects, resolve_current_project};
use crate::config::auth::{self, Auth};
use crate::config::settings::{ApiMode, read_config};

#[derive(Parser)]
pub struct Args {}
Expand All @@ -25,6 +29,8 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {
println!("source: {}", auth.source);
}

print_project_info(&auth).await;

if let Some(ref cfg) = config
&& let Some(ref browser) = cfg.browser
&& let Some(ref url) = browser.api_url
Expand All @@ -38,3 +44,27 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {

Ok(())
}

/// Print the project (environment) the API key is scoped to.
/// Best-effort: skipped in local mode, without a key, or on any fetch error,
/// so `steel config` stays usable offline.
async fn print_project_info(auth: &Auth) {
let (mode, base_url) = crate::util::api::resolve();
if mode != ApiMode::Cloud || auth.api_key.is_none() {
return;
}
let Ok(client) = SteelClient::new() else {
return;
};

let fetch = client.get_projects(&base_url, mode, auth);
let Ok(Ok(data)) = tokio::time::timeout(Duration::from_secs(2), fetch).await else {
return;
};

let projects = parse_projects(&data);
if let Some(project) = resolve_current_project(&projects) {
println!("project: {} ({})", project.name, project.slug);
println!("environment: {}", environment_label(project.is_production));
}
}
Loading