diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b578784..7e1690ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,6 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: [pre-commit, post-checkout] repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 @@ -25,3 +26,12 @@ repos: - id: clang-format files: ^crates/memtrack/src/ebpf/c/.*\.(c|h|bpf\.c)$ args: [--style=file, -i] + - repo: local + hooks: + - id: init-worktree + name: Initialize worktree + entry: scripts/init-worktree.sh + language: script + stages: [post-checkout] + always_run: true + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75297835..428e33b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,13 @@ # Contributing to CodSpeed Runner +## Initial Setup + +After cloning, install the pre-commit hooks: + +```bash +prek install +``` + ## Release Process This repository is a Cargo workspace containing multiple crates. The release process differs depending on which crate you're releasing. diff --git a/scripts/init-worktree.sh b/scripts/init-worktree.sh new file mode 100755 index 00000000..44989069 --- /dev/null +++ b/scripts/init-worktree.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Initialize a new worktree when it is first created. +# +# Runs from pre-commit's `post-checkout` stage. Git passes the all-zero SHA as +# the previous HEAD only during `git worktree add`, which is how we detect this +# case (pre-commit forwards it as PRE_COMMIT_FROM_REF). +set -eu + +ZERO_SHA="0000000000000000000000000000000000000000" + +if [ "${PRE_COMMIT_FROM_REF:-}" != "$ZERO_SHA" ]; then + exit 0 +fi + +MAIN_REPO=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') +CURRENT_DIR=$(pwd) + +if [ "$MAIN_REPO" = "$CURRENT_DIR" ]; then + exit 0 +fi + +printf "\n🌿 Initializing worktree from %s\n\n" "$MAIN_REPO" + +printf "šŸ“¦ Initializing submodules...\n" +git submodule update --init --recursive + +if command -v pre-commit >/dev/null 2>&1; then + printf "\nšŸŖ Installing pre-commit hooks...\n" + pre-commit install --hook-type pre-commit --hook-type post-checkout +fi diff --git a/src/cli/auth.rs b/src/cli/auth.rs index af8c9869..55311158 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -39,10 +39,13 @@ pub async fn run( args: AuthArgs, api_client: &CodSpeedAPIClient, config_name: Option<&str>, + config: CodSpeedConfig, ) -> Result<()> { match args.command { - AuthCommands::Login { with_token } => login(api_client, config_name, with_token).await?, - AuthCommands::Status => status(api_client).await?, + AuthCommands::Login { with_token } => { + login(api_client, config_name, config, with_token).await? + } + AuthCommands::Status => status(api_client, &config).await?, } Ok(()) } @@ -52,6 +55,7 @@ const LOGIN_SESSION_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 m async fn login( api_client: &CodSpeedAPIClient, config_name: Option<&str>, + mut config: CodSpeedConfig, with_token: bool, ) -> Result<()> { debug!("Login to CodSpeed"); @@ -118,12 +122,15 @@ async fn login( SessionError::Other(err) => err, })?; - let mut config = CodSpeedConfig::load_with_override(config_name, None)?; - config.auth.token = Some(token); + let selected = config.selected_profile_name().to_owned(); + config.profile_mut(&selected).auth.token = Some(token); config.persist(config_name)?; debug!("Token saved to configuration file"); - info!("Login successful, your are now authenticated on CodSpeed"); + info!( + "Login successful, you are now authenticated on CodSpeed (profile: {})", + config.selected_profile_name() + ); Ok(()) } @@ -147,8 +154,7 @@ struct AuthStatus { detected_repository: Option<(ParsedRepository, Option)>, } -pub async fn status(api_client: &CodSpeedAPIClient) -> Result<()> { - let config = CodSpeedConfig::load_with_override(None, None)?; +pub async fn status(api_client: &CodSpeedAPIClient, config: &CodSpeedConfig) -> Result<()> { let has_token = config.auth.token.is_some(); let parsed = detect_repository(); @@ -161,7 +167,11 @@ pub async fn status(api_client: &CodSpeedAPIClient) -> Result<()> { } }; - info!("{}", style("Authentication").bold()); + info!( + "{} (profile: {})", + style("Authentication").bold(), + config.selected_profile_name() + ); print_authentication_section(has_token, auth_status.session.as_ref()); info!(""); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7aa0a6f6..30ef9af7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ mod auth; pub(crate) mod exec; pub(crate) mod experimental; +mod profile; pub(crate) mod run; pub(crate) mod samply; mod setup; @@ -41,14 +42,8 @@ fn create_styles() -> Styles { #[command(version, about = "The CodSpeed CLI tool", styles = create_styles())] pub struct Cli { /// The URL of the CodSpeed GraphQL API - #[arg( - long, - env = "CODSPEED_API_URL", - global = true, - hide = true, - default_value = "https://gql.codspeed.io/" - )] - pub api_url: String, + #[arg(long, env = "CODSPEED_API_URL", global = true, hide = true)] + pub api_url: Option, /// The OAuth token to use for all requests #[arg(long, env = "CODSPEED_OAUTH_TOKEN", global = true, hide = true)] @@ -60,6 +55,10 @@ pub struct Cli { #[arg(long, env = "CODSPEED_CONFIG_NAME", global = true)] pub config_name: Option, + /// The CodSpeed profile to use + #[arg(long, env = "CODSPEED_PROFILE", global = true)] + pub profile: Option, + /// Path to project configuration file (codspeed.yaml) /// If provided, loads config from this path. Otherwise, searches for config files /// in the current directory and upward to the git root. @@ -88,6 +87,8 @@ enum Commands { Exec(Box), /// Manage the CLI authentication state Auth(auth::AuthArgs), + /// Manage CodSpeed profiles + Profile(profile::ProfileArgs), /// Pre-install the codspeed executors Setup(setup::SetupArgs), /// Show the overall status of CodSpeed (authentication, tools, system) @@ -130,7 +131,8 @@ impl InternalCommands { pub async fn run() -> Result<()> { let cli = Cli::parse(); - let mut api_client = build_api_client(&cli)?; + let codspeed_config = load_config(&cli)?; + let mut api_client = build_api_client(&cli, &codspeed_config); // Discover project configuration file let discovered_config = DiscoveredProjectConfig::discover_and_load( @@ -154,9 +156,13 @@ pub async fn run() -> Result<()> { match cli.command { Commands::Run(args) => { + let mut args = *args; + args.shared + .upload_url + .get_or_insert_with(|| codspeed_config.upload_url.clone()); args.shared.experimental.warn_if_active(); run::run( - *args, + args, &mut api_client, discovered_config.as_ref(), setup_cache_dir, @@ -164,18 +170,33 @@ pub async fn run() -> Result<()> { .await? } Commands::Exec(args) => { + let mut args = *args; + args.shared + .upload_url + .get_or_insert_with(|| codspeed_config.upload_url.clone()); args.shared.experimental.warn_if_active(); exec::run( - *args, + args, &mut api_client, discovered_config.as_ref().map(|d| &d.config), setup_cache_dir, ) .await? } - Commands::Auth(args) => auth::run(args, &api_client, cli.config_name.as_deref()).await?, + Commands::Auth(args) => { + auth::run( + args, + &api_client, + cli.config_name.as_deref(), + codspeed_config, + ) + .await? + } + Commands::Profile(args) => { + profile::run(args, cli.config_name.as_deref(), cli.profile.as_deref())? + } Commands::Setup(args) => setup::run(args, setup_cache_dir).await?, - Commands::Status => status::run(&api_client).await?, + Commands::Status => status::run(&api_client, &codspeed_config).await?, Commands::Use(args) => use_mode::run(args)?, Commands::Show => show::run()?, Commands::Update => update::run().await?, @@ -184,6 +205,23 @@ pub async fn run() -> Result<()> { Ok(()) } +/// Load the CodSpeed config for this invocation, resolving the active +/// profile (CLI `--profile` / `CODSPEED_PROFILE` / shell-session / built-in +/// `default`) and applying CLI overrides for the OAuth token and api URL. +/// +/// `auth` and `profile` subcommands are allowed to run against a config +/// where the selected profile does not yet exist (e.g. first-time setup). +fn load_config(cli: &Cli) -> Result { + CodSpeedConfig::load_with_profile( + cli.config_name.as_deref(), + cli.profile.as_deref(), + cli.oauth_token.as_deref(), + cli.api_url.as_deref(), + None, + matches!(&cli.command, Commands::Auth(_) | Commands::Profile(_)), + ) +} + /// Build the api client for this invocation, resolving the auth token /// from the most specific source available. This is the single source /// of truth for token resolution; the result lives on the returned @@ -193,13 +231,8 @@ pub async fn run() -> Result<()> { /// Priority (most specific first): /// 1. `--token` / `CODSPEED_TOKEN` — run/exec-level override /// 2. `--oauth-token` / `CODSPEED_OAUTH_TOKEN` and the persisted CLI -/// token — both live on disk and are loaded together by -/// [`CodSpeedConfig::load_with_override`]. -/// -/// The CLI config file is only read when no explicit token was passed, -/// so an invocation like `codspeed run --token ` never touches the -/// user's `~/.config/codspeed/`. -fn build_api_client(cli: &Cli) -> Result { +/// token from the selected profile. +fn build_api_client(cli: &Cli, config: &CodSpeedConfig) -> CodSpeedAPIClient { let explicit = match &cli.command { Commands::Run(args) => args.shared.token.clone(), Commands::Exec(args) => args.shared.token.clone(), @@ -207,14 +240,7 @@ fn build_api_client(cli: &Cli) -> Result { }; let token = match explicit { Some(token) => Some(token), - None => { - CodSpeedConfig::load_with_override( - cli.config_name.as_deref(), - cli.oauth_token.as_deref(), - )? - .auth - .token - } + None => config.auth.token.clone(), }; - Ok(CodSpeedAPIClient::new(token, cli.api_url.clone())) + CodSpeedAPIClient::new(token, config.api_url.clone()) } diff --git a/src/cli/profile.rs b/src/cli/profile.rs new file mode 100644 index 00000000..e7289c10 --- /dev/null +++ b/src/cli/profile.rs @@ -0,0 +1,129 @@ +use crate::config::{CodSpeedConfig, load_shell_session_profile, register_shell_session_profile}; +use crate::prelude::*; +use clap::{Args, Subcommand}; +use console::style; + +#[derive(Debug, Args)] +pub struct ProfileArgs { + #[command(subcommand)] + command: ProfileCommands, +} + +#[derive(Debug, Subcommand)] +enum ProfileCommands { + /// List configured profiles + List, + /// Show a profile + Show { + /// Profile name. Defaults to the configured default profile. + name: Option, + }, + /// Set profile URLs, creating the profile if it does not exist + Set { + /// Profile name + name: String, + /// The URL of the CodSpeed GraphQL API + #[arg(long)] + api_url: Option, + /// The URL to use for uploading results + #[arg(long)] + upload_url: Option, + }, + /// Set the active profile for the current shell session + Use { + /// Profile name + name: String, + }, +} + +pub fn run( + args: ProfileArgs, + config_name: Option<&str>, + selected_profile: Option<&str>, +) -> Result<()> { + match args.command { + ProfileCommands::List => list(config_name), + ProfileCommands::Show { name } => show(config_name, name.as_deref().or(selected_profile)), + ProfileCommands::Set { + name, + api_url, + upload_url, + } => set(config_name, &name, api_url, upload_url), + ProfileCommands::Use { name } => use_profile(config_name, &name), + } +} + +fn list(config_name: Option<&str>) -> Result<()> { + let config = CodSpeedConfig::load_with_override(config_name, None)?; + let active = load_shell_session_profile()?; + + info!("{}", style("Profiles").bold()); + for name in config.profiles().keys() { + let marker = if Some(name) == active.as_ref() { + "*" + } else { + " " + }; + info!(" {marker} {name}"); + } + + Ok(()) +} + +fn show(config_name: Option<&str>, profile_name: Option<&str>) -> Result<()> { + let config = + CodSpeedConfig::load_with_profile(config_name, profile_name, None, None, None, false)?; + + info!( + "{} ({})", + style("Profile").bold(), + config.selected_profile_name() + ); + info!(" api url: {}", config.api_url); + info!(" upload url: {}", config.upload_url); + info!( + " authenticated: {}", + if config.auth.token.is_some() { + "yes" + } else { + "no" + } + ); + + Ok(()) +} + +fn set( + config_name: Option<&str>, + profile_name: &str, + api_url: Option, + upload_url: Option, +) -> Result<()> { + let mut config = CodSpeedConfig::load_with_override(config_name, None)?; + let profile = config.profile_mut(profile_name); + + if let Some(api_url) = api_url { + profile.api_url = Some(api_url); + } + if let Some(upload_url) = upload_url { + profile.upload_url = Some(upload_url); + } + + config.persist(config_name)?; + info!("Profile `{profile_name}` saved"); + + Ok(()) +} + +fn use_profile(config_name: Option<&str>, profile_name: &str) -> Result<()> { + let config = CodSpeedConfig::load_with_override(config_name, None)?; + ensure!( + config.profile(profile_name).is_some(), + "CodSpeed profile `{profile_name}` does not exist. Run `codspeed profile set {profile_name}` to create it." + ); + + register_shell_session_profile(profile_name)?; + info!("Active CodSpeed profile set to `{profile_name}` for this shell session"); + + Ok(()) +} diff --git a/src/cli/show.rs b/src/cli/show.rs index 37a9e02f..64e1d123 100644 --- a/src/cli/show.rs +++ b/src/cli/show.rs @@ -1,7 +1,8 @@ use crate::prelude::*; +use crate::runner_mode::load_shell_session_mode; pub fn run() -> Result<()> { - let modes = crate::runner_mode::load_shell_session_mode()?; + let modes = load_shell_session_mode()?; if modes.is_empty() { info!("No mode set for this shell session"); diff --git a/src/cli/status.rs b/src/cli/status.rs index 13a5b1b7..c43505e4 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,5 +1,6 @@ use crate::VERSION; use crate::api_client::CodSpeedAPIClient; +use crate::config::CodSpeedConfig; use crate::prelude::*; use crate::system::SystemInfo; use console::style; @@ -16,9 +17,9 @@ pub fn warn_mark() -> console::StyledObject<&'static str> { style("!").yellow() } -pub async fn run(api_client: &CodSpeedAPIClient) -> Result<()> { +pub async fn run(api_client: &CodSpeedAPIClient, config: &CodSpeedConfig) -> Result<()> { // Auth status - super::auth::status(api_client).await?; + super::auth::status(api_client, config).await?; info!(""); // Setup/tools status diff --git a/src/cli/use_mode.rs b/src/cli/use_mode.rs index d2e5be4a..0a3eff32 100644 --- a/src/cli/use_mode.rs +++ b/src/cli/use_mode.rs @@ -1,7 +1,7 @@ //! Named like this because `use` is a keyword use crate::prelude::*; -use crate::runner_mode::RunnerMode; +use crate::runner_mode::{RunnerMode, register_shell_session_mode}; use clap::Args; #[derive(Debug, Args)] @@ -14,7 +14,7 @@ pub struct UseArgs { } pub fn run(args: UseArgs) -> Result<()> { - crate::runner_mode::register_shell_session_mode(&args.mode)?; + register_shell_session_mode(&args.mode)?; debug!( "Registered codspeed use mode '{:?}' for this shell session (parent PID)", args.mode diff --git a/src/config.rs b/src/config.rs index 1e3fdf94..470f9207 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,27 +1,117 @@ -use std::{env, fs, path::PathBuf}; +use std::{collections::BTreeMap, env, fs, path::PathBuf}; +use crate::executor::config::DEFAULT_UPLOAD_URL; use crate::prelude::*; -use nestify::nest; +use crate::shell_session_store; use serde::{Deserialize, Serialize}; -nest! { - #[derive(Debug, Deserialize, Serialize)]* - #[serde(rename_all = "kebab-case")]* - /// Persistent configuration for CodSpeed CLI. - /// - /// This struct represents the user's persistent configuration stored in the filesystem, - /// typically at `~/.config/codspeed/config.yaml`. It contains settings that persist - /// across multiple benchmark runs, such as authentication credentials. - /// - /// The configuration follows the XDG Base Directory Specification and can be loaded - /// with [`CodSpeedConfig::load_with_override`] or persisted with [`CodSpeedConfig::persist`]. - pub struct CodSpeedConfig { - pub auth: pub struct AuthConfig { - pub token: Option, +pub const DEFAULT_API_URL: &str = "https://gql.codspeed.io/"; +pub const DEFAULT_PROFILE_NAME: &str = "default"; + +/// Current on-disk schema version. Bump when introducing a new migration. +const CURRENT_CONFIG_VERSION: u32 = 1; + +const SHELL_SESSION_PROFILE_KIND: &str = "codspeed_profile"; + +/// Persist `profile_name` as the active profile for the current shell session. +pub fn register_shell_session_profile(profile_name: &str) -> Result<()> { + shell_session_store::register(SHELL_SESSION_PROFILE_KIND, &profile_name.to_owned()) +} + +/// Look up the active profile for the current shell session, if any. +pub fn load_shell_session_profile() -> Result> { + shell_session_store::load::(SHELL_SESSION_PROFILE_KIND) +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AuthConfig { + pub token: Option, +} + +impl AuthConfig { + fn is_empty(&self) -> bool { + self.token.is_none() + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ProfileConfig { + #[serde(default, skip_serializing_if = "AuthConfig::is_empty")] + pub auth: AuthConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upload_url: Option, +} + +/// Raw shape read from disk. Captures every YAML field we have ever +/// written, including legacy ones, so `migrate` can fold them into the +/// canonical [`PersistedConfig`]. This type is the only place legacy +/// fields are mentioned — it is private to this module. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct RawConfig { + #[serde(default)] + version: Option, + /// v0 legacy: token used to live at the top level. Migrated into + /// `profiles.default.auth.token` on load. + #[serde(default)] + auth: Option, + #[serde(default)] + profiles: BTreeMap, +} + +/// The on-disk shape: schema version + named profiles. This is what +/// `serde_yaml` writes back when we persist. It carries no resolved +/// runtime state — the runtime view lives in [`CodSpeedConfig`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +struct PersistedConfig { + version: u32, + profiles: BTreeMap, +} + +impl Default for PersistedConfig { + fn default() -> Self { + let mut profiles = BTreeMap::new(); + profiles.insert(DEFAULT_PROFILE_NAME.to_owned(), ProfileConfig::default()); + Self { + version: CURRENT_CONFIG_VERSION, + profiles, } } } +/// Configuration as seen at runtime: the persisted state plus the +/// resolved auth/URLs/profile selected for this invocation. +/// +/// Stored at `~/.config/codspeed/config.yaml` by default. Legacy YAML +/// formats are normalised by `migrate` at load time and re-persisted +/// immediately, so the on-disk shape always matches +/// [`PersistedConfig`]. +#[derive(Debug, Clone)] +pub struct CodSpeedConfig { + persisted: PersistedConfig, + pub auth: AuthConfig, + pub api_url: String, + pub upload_url: String, + selected_profile: String, +} + +fn default_profile_name() -> String { + DEFAULT_PROFILE_NAME.to_owned() +} + +fn default_api_url() -> String { + DEFAULT_API_URL.to_owned() +} + +fn default_upload_url() -> String { + DEFAULT_UPLOAD_URL.to_owned() +} + /// Get the path to the configuration file, following the XDG Base Directory Specification /// at https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html /// @@ -42,58 +132,337 @@ fn get_configuration_file_path(config_name: Option<&str>) -> PathBuf { } } -impl Default for CodSpeedConfig { - fn default() -> Self { +impl CodSpeedConfig { + /// Wrap a [`PersistedConfig`] with placeholder runtime values. The + /// caller is expected to invoke [`Self::resolve_selected_profile`] + /// to populate them before exposing the result. + fn from_persisted(persisted: PersistedConfig) -> Self { Self { - auth: AuthConfig { token: None }, + persisted, + auth: AuthConfig::default(), + api_url: default_api_url(), + upload_url: default_upload_url(), + selected_profile: default_profile_name(), + } + } +} + +/// Fold a [`RawConfig`] into the canonical [`PersistedConfig`], +/// returning the upgraded persisted state and a flag indicating +/// whether the on-disk shape needed changes (in which case the caller +/// should re-persist). +fn migrate(raw: RawConfig) -> (PersistedConfig, bool) { + let raw_version = raw.version.unwrap_or(0); + let mut dirty = raw_version != CURRENT_CONFIG_VERSION; + + let mut profiles = raw.profiles; + + // v0 → v1: move legacy top-level auth.token into profiles.default + // (only if the profile slot is empty, so we don't clobber a value + // the user explicitly set per-profile). + if let Some(legacy_auth) = raw.auth + && let Some(token) = legacy_auth.token + { + dirty = true; + let default_profile = profiles.entry(DEFAULT_PROFILE_NAME.to_owned()).or_default(); + if default_profile.auth.token.is_none() { + default_profile.auth.token = Some(token); } } + + // Ensure the default profile exists so consumers can rely on it. + profiles.entry(DEFAULT_PROFILE_NAME.to_owned()).or_default(); + + ( + PersistedConfig { + version: CURRENT_CONFIG_VERSION, + profiles, + }, + dirty, + ) +} + +/// Write the canonical [`PersistedConfig`] to disk. +fn write_persisted(persisted: &PersistedConfig, config_name: Option<&str>) -> Result<()> { + let config_path = get_configuration_file_path(config_name); + fs::create_dir_all(config_path.parent().unwrap())?; + let config_str = serde_yaml::to_string(persisted)?; + fs::write(&config_path, config_str)?; + debug!("Config written to {}", config_path.display()); + Ok(()) } impl CodSpeedConfig { /// Load the configuration. If it does not exist, return a default configuration. /// - /// If oauth_token_override is provided, the token from the loaded configuration will be - /// ignored, and the override will be used instead + /// If oauth_token_override is provided, the token from the selected profile will be + /// ignored when resolving the effective configuration. pub fn load_with_override( config_name: Option<&str>, oauth_token_override: Option<&str>, + ) -> Result { + Self::load_with_profile(config_name, None, oauth_token_override, None, None, false) + } + + pub fn load_with_profile( + config_name: Option<&str>, + profile_name: Option<&str>, + oauth_token_override: Option<&str>, + api_url_override: Option<&str>, + upload_url_override: Option<&str>, + allow_missing_profile: bool, ) -> Result { let config_path = get_configuration_file_path(config_name); - let mut config = match fs::read(&config_path) { + let (persisted, was_migrated) = match fs::read(&config_path) { Ok(config_str) => { - let config: CodSpeedConfig = - serde_yaml::from_slice(&config_str).context(format!( - "Failed to parse CodSpeed config at {}", - config_path.display() - ))?; + let raw: RawConfig = serde_yaml::from_slice(&config_str).context(format!( + "Failed to parse CodSpeed config at {}", + config_path.display() + ))?; debug!("Config loaded from {}", config_path.display()); - config + migrate(raw) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { debug!("Config file not found at {}", config_path.display()); - CodSpeedConfig::default() + (PersistedConfig::default(), false) } Err(e) => bail!("Failed to load config: {e}"), }; - if let Some(oauth_token) = oauth_token_override { - config.auth.token = Some(oauth_token.to_owned()); + if was_migrated { + debug!( + "Upgrading config at {} to v{CURRENT_CONFIG_VERSION}", + config_path.display() + ); + write_persisted(&persisted, config_name)?; } + let mut config = Self::from_persisted(persisted); + + config.resolve_selected_profile( + profile_name, + oauth_token_override, + api_url_override, + upload_url_override, + allow_missing_profile, + )?; + Ok(config) } - /// Persist changes to the configuration + /// Persist the canonical on-disk configuration. Runtime resolved + /// fields (auth, api_url, upload_url) are not written — only what + /// is in [`PersistedConfig`]. pub fn persist(&self, config_name: Option<&str>) -> Result<()> { - let config_path = get_configuration_file_path(config_name); - fs::create_dir_all(config_path.parent().unwrap())?; + write_persisted(&self.persisted, config_name) + } + + pub fn selected_profile_name(&self) -> &str { + &self.selected_profile + } - let config_str = serde_yaml::to_string(self)?; - fs::write(&config_path, config_str)?; - debug!("Config written to {}", config_path.display()); + pub fn profiles(&self) -> &BTreeMap { + &self.persisted.profiles + } + + pub fn profile(&self, profile_name: &str) -> Option<&ProfileConfig> { + self.persisted.profiles.get(profile_name) + } + + pub fn profile_mut(&mut self, profile_name: &str) -> &mut ProfileConfig { + self.persisted + .profiles + .entry(profile_name.to_owned()) + .or_default() + } + + fn resolve_selected_profile( + &mut self, + profile_name: Option<&str>, + oauth_token_override: Option<&str>, + api_url_override: Option<&str>, + upload_url_override: Option<&str>, + allow_missing_profile: bool, + ) -> Result<()> { + let selected_profile_name = match profile_name { + Some(name) => name.to_owned(), + None => load_shell_session_profile() + .unwrap_or_default() + .unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_owned()), + }; + let profile = match self.profile(&selected_profile_name) { + Some(profile) => profile.clone(), + None if allow_missing_profile => ProfileConfig::default(), + None => { + bail!( + "CodSpeed profile `{selected_profile_name}` does not exist. Run `codspeed profile set {selected_profile_name}` to create it." + ); + } + }; + + self.selected_profile = selected_profile_name; + self.auth = AuthConfig { + token: oauth_token_override + .map(ToOwned::to_owned) + .or(profile.auth.token), + }; + self.api_url = api_url_override + .map(ToOwned::to_owned) + .or(profile.api_url) + .unwrap_or_else(default_api_url); + self.upload_url = upload_url_override + .map(ToOwned::to_owned) + .or(profile.upload_url) + .unwrap_or_else(default_upload_url); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn parse_raw(yaml: &str) -> RawConfig { + serde_yaml::from_str(yaml).unwrap() + } + + #[test] + fn migrate_v0_with_top_level_auth_token() { + let (persisted, dirty) = migrate(parse_raw( + r#" +auth: + token: old-token +"#, + )); + + assert!(dirty); + assert_eq!(persisted.version, CURRENT_CONFIG_VERSION); + assert_eq!( + persisted.profiles[DEFAULT_PROFILE_NAME] + .auth + .token + .as_deref(), + Some("old-token") + ); + } + + #[test] + fn migrate_canonical_is_idempotent() { + let (persisted, dirty) = migrate(parse_raw( + r#" +version: 1 +profiles: + default: + auth: + token: tok +"#, + )); + + assert!(!dirty); + assert_eq!(persisted.version, CURRENT_CONFIG_VERSION); + assert_eq!( + persisted.profiles[DEFAULT_PROFILE_NAME] + .auth + .token + .as_deref(), + Some("tok") + ); + } + + #[test] + fn migrate_preserves_existing_profile_token() { + let (persisted, dirty) = migrate(parse_raw( + r#" +auth: + token: legacy-token +profiles: + default: + auth: + token: profile-token +"#, + )); + + assert!(dirty); + // existing per-profile token wins + assert_eq!( + persisted.profiles[DEFAULT_PROFILE_NAME] + .auth + .token + .as_deref(), + Some("profile-token") + ); + } + + #[test] + fn resolves_profile_values_with_overrides() { + let mut config = CodSpeedConfig::from_persisted(PersistedConfig::default()); + let profile = config.profile_mut("staging"); + profile.auth.token = Some("profile-token".into()); + profile.api_url = Some("https://gql.staging.example/".into()); + profile.upload_url = Some("https://api.staging.example/upload".into()); + + config + .resolve_selected_profile( + Some("staging"), + Some("override-token"), + Some("https://gql.override.example/"), + None, + false, + ) + .unwrap(); + + assert_eq!(config.selected_profile_name(), "staging"); + assert_eq!(config.auth.token.as_deref(), Some("override-token")); + assert_eq!(config.api_url, "https://gql.override.example/"); + assert_eq!(config.upload_url, "https://api.staging.example/upload"); + } + + #[test] + fn load_rewrites_legacy_file_in_canonical_form() { + let tmp = TempDir::new().unwrap(); + // SAFETY: tests run single-threaded by default but env mutations + // affect the whole process; this test does not parallelise with + // others that mutate XDG_CONFIG_HOME. + unsafe { + env::set_var("XDG_CONFIG_HOME", tmp.path()); + } + + let config_dir = tmp.path().join("codspeed"); + fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("config.yaml"); + fs::write( + &config_path, + "auth:\n token: legacy-token\nprofiles:\n staging: {}\n", + ) + .unwrap(); + + CodSpeedConfig::load_with_profile(None, Some("staging"), None, None, None, false).unwrap(); + + let on_disk = fs::read_to_string(&config_path).unwrap(); + assert!( + on_disk.starts_with("version: 1\n"), + "expected version preamble, got:\n{on_disk}" + ); + // top-level legacy auth: gone (only profile-level auth: remains) + assert!( + !on_disk.contains("\nauth:\n"), + "legacy top-level auth should be gone, got:\n{on_disk}" + ); + assert!( + on_disk.contains("legacy-token"), + "token should be migrated into profiles.default, got:\n{on_disk}" + ); + + // second load is a no-op on disk + let mtime_before = fs::metadata(&config_path).unwrap().modified().unwrap(); + CodSpeedConfig::load_with_profile(None, Some("staging"), None, None, None, false).unwrap(); + let mtime_after = fs::metadata(&config_path).unwrap().modified().unwrap(); + assert_eq!( + mtime_before, mtime_after, + "canonical file should not be rewritten" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7a555e51..fe86e3e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ mod project_config; mod request_client; mod run_environment; mod runner_mode; +mod shell_session_store; mod system; mod upload; diff --git a/src/runner_mode.rs b/src/runner_mode.rs new file mode 100644 index 00000000..d105457f --- /dev/null +++ b/src/runner_mode.rs @@ -0,0 +1,28 @@ +use crate::prelude::*; +use crate::shell_session_store; +use clap::ValueEnum; +use serde::Deserialize; +use serde::Serialize; + +const SHELL_SESSION_KIND: &str = "codspeed_use_mode"; + +#[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RunnerMode { + #[deprecated(note = "Use `RunnerMode::Simulation` instead")] + Instrumentation, + Simulation, + Walltime, + Memory, +} + +/// Register the active runner mode(s) for the current shell session. +pub(crate) fn register_shell_session_mode(modes: &[RunnerMode]) -> Result<()> { + shell_session_store::register(SHELL_SESSION_KIND, &modes.to_vec()) +} + +/// Load the active runner mode(s) for the current shell session, or +/// an empty vector if none has been set. +pub(crate) fn load_shell_session_mode() -> Result> { + Ok(shell_session_store::load::>(SHELL_SESSION_KIND)?.unwrap_or_default()) +} diff --git a/src/runner_mode/mod.rs b/src/runner_mode/mod.rs deleted file mode 100644 index 4d84660c..00000000 --- a/src/runner_mode/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -use clap::ValueEnum; -use serde::Deserialize; -use serde::Serialize; - -mod shell_session; - -pub(crate) use shell_session::load_shell_session_mode; -pub(crate) use shell_session::register_shell_session_mode; - -#[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum RunnerMode { - #[deprecated(note = "Use `RunnerMode::Simulation` instead")] - Instrumentation, - Simulation, - Walltime, - Memory, -} diff --git a/src/runner_mode/shell_session.rs b/src/runner_mode/shell_session.rs deleted file mode 100644 index 46586563..00000000 --- a/src/runner_mode/shell_session.rs +++ /dev/null @@ -1,77 +0,0 @@ -use super::RunnerMode; -use crate::prelude::*; -use libc::pid_t; -use std::path::Path; -use std::path::PathBuf; -use std::sync::OnceLock; -use sysinfo::Pid; -use sysinfo::ProcessRefreshKind; -use sysinfo::RefreshKind; -use sysinfo::System; - -static SYSTEM: OnceLock = OnceLock::new(); - -/// Get the root directory where the use mode is stored -/// If available, uses `$XDG_RUNTIME_DIR/codspeed_use_mode` -/// Otherwise, falls back to `std::env::temp_dir()/codspeed_use_mode` -fn get_use_mode_root_dir() -> PathBuf { - let base_dir = if let Some(xdg_runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { - PathBuf::from(xdg_runtime_dir) - } else { - std::env::temp_dir() - }; - - base_dir.join("codspeed_use_mode") -} - -fn get_parent_pid(pid: pid_t) -> Option { - let s = SYSTEM.get_or_init(|| { - System::new_with_specifics( - RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()), - ) - }); - - let current_pid = Pid::from_u32(pid as u32); - - s.process(current_pid) - .and_then(|p| p.parent()) - .map(|pid| pid.as_u32() as pid_t) -} - -fn get_mode_file_path(base_dir: &Path, pid: pid_t) -> PathBuf { - base_dir.join(pid.to_string()) -} - -pub(crate) fn register_shell_session_mode(mode: &[RunnerMode]) -> Result<()> { - let use_mode_dir = get_use_mode_root_dir(); - std::fs::create_dir_all(&use_mode_dir)?; - - let Some(parent_pid) = get_parent_pid(std::process::id() as pid_t) else { - return Err(anyhow!("Could not determine parent PID")); - }; - - let mode_file_path = get_mode_file_path(&use_mode_dir, parent_pid); - - std::fs::write(mode_file_path, serde_json::to_string(mode)?)?; - Ok(()) -} - -pub(crate) fn load_shell_session_mode() -> Result> { - // Go up the process tree until we find a registered mode - let mut current_pid = std::process::id() as pid_t; - - while let Some(parent_pid) = get_parent_pid(current_pid) { - let use_mode_dir = get_use_mode_root_dir(); - let mode_file_path = get_mode_file_path(&use_mode_dir, parent_pid); - - if mode_file_path.exists() { - let mode_str = std::fs::read_to_string(mode_file_path)?; - let mode: Vec = serde_json::from_str(&mode_str)?; - return Ok(mode); - } - - current_pid = parent_pid; - } - - Ok(Vec::new()) -} diff --git a/src/shell_session_store.rs b/src/shell_session_store.rs new file mode 100644 index 00000000..79001167 --- /dev/null +++ b/src/shell_session_store.rs @@ -0,0 +1,81 @@ +//! Shell-session-scoped key/value state, keyed by the parent shell's PID. +//! +//! State is written to `$XDG_RUNTIME_DIR//` (or the system +//! temp dir if `XDG_RUNTIME_DIR` is unset). Loading walks up the process tree +//! until a registered file is found, so the value is shared across subshells +//! of the shell that registered it. + +use crate::prelude::*; +use libc::pid_t; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; +use sysinfo::Pid; +use sysinfo::ProcessRefreshKind; +use sysinfo::RefreshKind; +use sysinfo::System; + +static SYSTEM: OnceLock = OnceLock::new(); + +fn get_root_dir(kind: &str) -> PathBuf { + let base_dir = if let Some(xdg_runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + PathBuf::from(xdg_runtime_dir) + } else { + std::env::temp_dir() + }; + + base_dir.join(kind) +} + +fn get_parent_pid(pid: pid_t) -> Option { + let s = SYSTEM.get_or_init(|| { + System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()), + ) + }); + + let current_pid = Pid::from_u32(pid as u32); + + s.process(current_pid) + .and_then(|p| p.parent()) + .map(|pid| pid.as_u32() as pid_t) +} + +fn get_state_file_path(base_dir: &Path, pid: pid_t) -> PathBuf { + base_dir.join(pid.to_string()) +} + +/// Persist `value` for the current shell session (keyed by the parent PID of +/// this process). +pub(crate) fn register(kind: &str, value: &T) -> Result<()> { + let dir = get_root_dir(kind); + std::fs::create_dir_all(&dir)?; + + let parent_pid = + get_parent_pid(std::process::id() as pid_t).context("Could not determine parent PID")?; + + let path = get_state_file_path(&dir, parent_pid); + std::fs::write(path, serde_json::to_string(value)?)?; + Ok(()) +} + +/// Look up a previously-registered value by walking up the process tree from +/// this process. Returns `None` if no ancestor has registered a value. +pub(crate) fn load(kind: &str) -> Result> { + let dir = get_root_dir(kind); + let mut current_pid = std::process::id() as pid_t; + + while let Some(parent_pid) = get_parent_pid(current_pid) { + let path = get_state_file_path(&dir, parent_pid); + if path.exists() { + let raw = std::fs::read_to_string(path)?; + let value: T = serde_json::from_str(&raw)?; + return Ok(Some(value)); + } + current_pid = parent_pid; + } + + Ok(None) +}