indusagi-core 0.1.0

Cross-cutting primitives every indusagi crate depends on: cancellation, env registry, brand, locator, canonical-JSON, version, ids, errors, re-iterable channel.
Documentation
//! The single source of naming truth for the whole application.
//!
//! Every user-visible name the product imprints on the world — the spoken app
//! name, the executable users type, the dot-directory under `$HOME`, the
//! `INDUSAGI_*` env prefix, and the basenames of on-disk artifacts — is declared
//! once, here. Nothing else hard-codes these strings; everything derives from
//! [`BRAND`]. A clean rebrand is a one-file edit.
//!
//! Ported from `shell-app/locate/brand.ts`. Rust immutability is the default, so
//! no `Object.freeze` is needed — `BRAND` is a plain const.

/// The shape of the brand record. Every field is a `&'static str` so the whole
/// record is a compile-time constant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Brand {
    /// The product's spoken name, used in help banners and prose.
    pub app_name: &'static str,
    /// The executable name users invoke on the command line.
    pub bin_name: &'static str,
    /// The prefix stamped on every env var the app reads or writes, including
    /// the trailing underscore (e.g. `"INDUSAGI_"`). Compose with [`env_name`].
    pub env_prefix: &'static str,
    /// The dot-directory under the user's home for all per-user state
    /// (e.g. `".indusagi"`). Leading dot included so it is hidden.
    pub profile_dir_name: &'static str,
    /// Basename of the global settings file inside the profile directory.
    pub settings_file_name: &'static str,
    /// Basename of the credential/auth store file inside the profile directory.
    pub auth_store_file_name: &'static str,
    /// Subdirectory (under the profile) holding persisted conversation sessions.
    pub sessions_dir_name: &'static str,
    /// Subdirectory (under the profile) holding diagnostic and crash logs.
    pub logs_dir_name: &'static str,
    /// Basename of the per-project settings file (lives in the project's own
    /// dot-directory, not under the home profile).
    pub project_settings_file_name: &'static str,
    /// The dot-directory a project keeps in its own root for project-scoped
    /// settings (e.g. `".indusagi"`), rooted at the working directory.
    pub project_dir_name: &'static str,
}

/// The canonical brand for this build of the product.
pub const BRAND: Brand = Brand {
    app_name: "indusagi",
    bin_name: "indusagi",
    env_prefix: "INDUSAGI_",
    profile_dir_name: ".indusagi",
    settings_file_name: "settings.json",
    auth_store_file_name: "auth.json",
    sessions_dir_name: "sessions",
    logs_dir_name: "logs",
    project_settings_file_name: "settings.json",
    project_dir_name: ".indusagi",
};

/// Compose a fully-qualified environment variable name from a bare suffix,
/// against an explicit brand.
///
/// The suffix is trimmed, every run of whitespace-or-hyphen is folded to a
/// single underscore, the result is upper-cased, and the brand's `env_prefix`
/// is prepended. So `env_name_with("api key", BRAND)` yields
/// `"INDUSAGI_API_KEY"`. This reproduces the TS grammar
/// `suffix.trim().replace(/[\s-]+/g, "_").toUpperCase()` byte-for-byte.
pub fn env_name_with(suffix: &str, brand: &Brand) -> String {
    let mut out = String::with_capacity(brand.env_prefix.len() + suffix.len());
    out.push_str(brand.env_prefix);

    // Fold runs of [whitespace|hyphen] to a single '_', upper-case the rest.
    let mut prev_was_separator = false;
    for ch in suffix.trim().chars() {
        if ch.is_whitespace() || ch == '-' {
            if !prev_was_separator {
                out.push('_');
                prev_was_separator = true;
            }
        } else {
            // `to_uppercase` can yield multiple chars (e.g. ß); match JS by
            // pushing all of them.
            for up in ch.to_uppercase() {
                out.push(up);
            }
            prev_was_separator = false;
        }
    }
    out
}

/// Compose a branded env var name against the canonical [`BRAND`].
pub fn env_name(suffix: &str) -> String {
    env_name_with(suffix, &BRAND)
}

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

    #[test]
    fn brand_constants_match_the_frozen_layout() {
        assert_eq!(BRAND.app_name, "indusagi");
        assert_eq!(BRAND.bin_name, "indusagi");
        assert_eq!(BRAND.env_prefix, "INDUSAGI_");
        assert_eq!(BRAND.profile_dir_name, ".indusagi");
        assert_eq!(BRAND.auth_store_file_name, "auth.json");
        assert_eq!(BRAND.sessions_dir_name, "sessions");
    }

    #[test]
    fn env_name_grammar_is_byte_exact() {
        assert_eq!(env_name("api key"), "INDUSAGI_API_KEY");
        assert_eq!(env_name("HOME"), "INDUSAGI_HOME");
        assert_eq!(env_name("home"), "INDUSAGI_HOME");
        // Runs of mixed whitespace/hyphen fold to a single underscore.
        assert_eq!(env_name("multi   word"), "INDUSAGI_MULTI_WORD");
        assert_eq!(env_name("dash-separated"), "INDUSAGI_DASH_SEPARATED");
        assert_eq!(env_name("a - b"), "INDUSAGI_A_B");
        // Surrounding whitespace is trimmed (no leading/trailing underscore).
        assert_eq!(env_name("  spaced  "), "INDUSAGI_SPACED");
    }
}