use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Args as ClapArgs, ValueEnum};
use scrybe_core::config::{
Config, CONFIG_FILE_NAME, RECORD_LLM_OPENAI_COMPAT, RECORD_SOURCE_MIC_SYSTEM,
};
use scrybe_core::storage::atomic_replace;
const DEFAULT_MAC_LOCAL_LLM_MODEL: &str = "gemma4:latest";
#[derive(ClapArgs, Debug)]
pub struct Args {
#[arg(long, default_value_t = false)]
pub force: bool,
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long, value_enum)]
pub profile: Option<InitProfile>,
#[arg(long)]
pub whisper_model: Option<PathBuf>,
#[arg(long)]
pub llm_model: Option<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum InitProfile {
Default,
#[value(name = "mac-local")]
MacLocal,
}
pub async fn run(args: Args) -> Result<()> {
let target = match &args.path {
Some(p) => p.clone(),
None => Config::default_path().context("resolving default config path")?,
};
if target.exists() && !args.force {
anyhow::bail!(
"config already exists at {}; pass --force to overwrite",
target.display()
);
}
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("creating config directory {}", parent.display()))?;
} else {
anyhow::bail!(
"config target has no parent directory: {}",
target.display()
);
}
let config = build_config(&args)?;
let body = toml::to_string_pretty(&config).context("serializing default config")?;
atomic_replace(&target, body.as_bytes())
.with_context(|| format!("writing {}", target.display()))?;
println!(
"scrybe init: wrote {} ({} bytes)",
target.display(),
body.len()
);
println!("config file name: {CONFIG_FILE_NAME}");
Ok(())
}
fn build_config(args: &Args) -> Result<Config> {
let mut config = Config::default();
match resolved_profile(args.profile) {
InitProfile::Default => {
if args.whisper_model.is_some() || args.llm_model.is_some() {
anyhow::bail!("--whisper-model and --llm-model require --profile mac-local");
}
}
InitProfile::MacLocal => {
if let Some(whisper_model) = &args.whisper_model {
config.stt.model = whisper_model
.to_str()
.context("Whisper model path is not valid UTF-8")?
.to_string();
}
config.record.source = RECORD_SOURCE_MIC_SYSTEM.to_string();
config.record.llm = RECORD_LLM_OPENAI_COMPAT.to_string();
config.llm.model = args
.llm_model
.clone()
.unwrap_or_else(|| DEFAULT_MAC_LOCAL_LLM_MODEL.to_string());
}
}
Ok(config)
}
fn resolved_profile(profile: Option<InitProfile>) -> InitProfile {
profile.unwrap_or_else(platform_default_profile)
}
const fn platform_default_profile() -> InitProfile {
if cfg!(target_os = "macos") {
InitProfile::MacLocal
} else {
InitProfile::Default
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_init_writes_config_file_at_supplied_path() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("config.toml");
run(Args {
force: false,
path: Some(target.clone()),
profile: Some(InitProfile::Default),
whisper_model: None,
llm_model: None,
})
.await
.unwrap();
assert!(target.exists());
let body = std::fs::read_to_string(&target).unwrap();
let parsed = Config::from_toml_str(&body, &target).unwrap();
assert_eq!(parsed.stt.model, "small.en");
}
#[tokio::test]
async fn test_init_refuses_to_overwrite_existing_config_without_force() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("config.toml");
std::fs::write(&target, b"schema_version = 1\n").unwrap();
let err = run(Args {
force: false,
path: Some(target.clone()),
profile: Some(InitProfile::Default),
whisper_model: None,
llm_model: None,
})
.await
.unwrap_err();
assert!(err.to_string().contains("config already exists"));
}
#[tokio::test]
async fn test_init_overwrites_existing_config_when_force_set() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("config.toml");
std::fs::write(&target, b"schema_version = 0\n").unwrap();
run(Args {
force: true,
path: Some(target.clone()),
profile: Some(InitProfile::Default),
whisper_model: None,
llm_model: None,
})
.await
.unwrap();
let body = std::fs::read_to_string(&target).unwrap();
assert!(body.contains("schema_version = 1"));
}
#[tokio::test]
async fn test_init_writes_config_round_trips_to_default() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("config.toml");
run(Args {
force: false,
path: Some(target.clone()),
profile: Some(InitProfile::Default),
whisper_model: None,
llm_model: None,
})
.await
.unwrap();
let body = std::fs::read_to_string(&target).unwrap();
let parsed = Config::from_toml_str(&body, &target).unwrap();
assert_eq!(
parsed,
Config::default(),
"init must write a config that round-trips back to Config::default()"
);
}
#[tokio::test]
async fn test_init_mac_local_profile_writes_record_defaults() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("config.toml");
let model_path = dir.path().join("ggml-base.en.bin");
run(Args {
force: false,
path: Some(target.clone()),
profile: Some(InitProfile::MacLocal),
whisper_model: Some(model_path.clone()),
llm_model: Some("gemma4:latest".to_string()),
})
.await
.unwrap();
let body = std::fs::read_to_string(&target).unwrap();
let parsed = Config::from_toml_str(&body, &target).unwrap();
assert_eq!(parsed.record.source, RECORD_SOURCE_MIC_SYSTEM);
assert_eq!(parsed.stt.model, model_path.to_str().unwrap());
assert_eq!(parsed.record.whisper_model, None);
assert_eq!(parsed.record.llm, RECORD_LLM_OPENAI_COMPAT);
assert_eq!(parsed.llm.model, "gemma4:latest");
}
#[test]
fn test_init_mac_local_profile_supplies_local_defaults() {
let config = build_config(&Args {
force: false,
path: None,
profile: Some(InitProfile::MacLocal),
whisper_model: None,
llm_model: None,
})
.unwrap();
assert_eq!(config.record.source, RECORD_SOURCE_MIC_SYSTEM);
assert_eq!(config.stt.model, "small.en");
assert_eq!(config.record.whisper_model, None);
assert_eq!(config.record.llm, RECORD_LLM_OPENAI_COMPAT);
assert_eq!(config.llm.model, DEFAULT_MAC_LOCAL_LLM_MODEL);
}
#[test]
fn test_init_uses_mac_local_as_platform_default_on_macos() {
let expected = platform_default_profile();
assert_eq!(resolved_profile(None), expected);
}
}