use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use regex::Regex;
use serde::{Deserialize, Serialize};
use teravars::{Engine, discover_config_files, load_merged, system_context};
use crate::paths::ShokaPaths;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShokaConfig {
pub global: GlobalConfig,
#[serde(default)]
pub routes: Vec<Route>,
#[serde(default)]
pub profiles: BTreeMap<String, ProfileConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalConfig {
pub root: Option<String>,
pub layout: String,
pub default_vcs: VcsDefault,
pub default_protocol: Protocol,
pub default_host: String,
pub default_profile: Option<String>,
pub exec_concurrency: usize,
pub ui: UiConfig,
pub shell: ShellConfig,
pub cache: CacheConfig,
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
root: None,
layout: "{{ root }}/{{ host }}/{{ owner }}/{{ name }}".into(),
default_vcs: VcsDefault::Auto,
default_protocol: Protocol::Https,
default_host: "github.com".into(),
default_profile: None,
exec_concurrency: 8,
ui: UiConfig::default(),
shell: ShellConfig::default(),
cache: CacheConfig::default(),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VcsDefault {
#[default]
Auto,
Git,
Jj,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
#[default]
Https,
Ssh,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub status_cache_ttl_secs: u64,
pub tui_refresh_ms: u64,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
status_cache_ttl_secs: 60,
tui_refresh_ms: 250,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellConfig {
pub cd_command_name: String,
}
impl Default for ShellConfig {
fn default() -> Self {
Self {
cd_command_name: "s".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub background_refresh: bool,
pub refresh_threshold_secs: u64,
pub parallel_repos: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
background_refresh: true,
refresh_threshold_secs: 60,
parallel_repos: 8,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_vcs: Option<VcsDefault>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_protocol: Option<Protocol>,
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub raw: Route,
pub pattern: PatternKind,
pub resolved_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub enum PatternKind {
HostExact(String),
HostOwner(String),
Regex(Regex),
}
impl PatternKind {
pub fn matches(&self, spec: &str) -> bool {
match self {
PatternKind::HostExact(h) | PatternKind::HostOwner(h) => prefix_or_exact_match(spec, h),
PatternKind::Regex(re) => re.is_match(spec),
}
}
}
fn prefix_or_exact_match(spec: &str, prefix: &str) -> bool {
spec.starts_with(prefix)
&& (spec.len() == prefix.len() || spec.as_bytes().get(prefix.len()) == Some(&b'/'))
}
pub fn compile_pattern(s: &str) -> Result<PatternKind> {
if let Some(rest) = s.strip_prefix("host:") {
if rest.is_empty() {
bail!("`host:` pattern requires a host name: `{s}`");
}
if let Some((host, owner)) = rest.split_once('/') {
if host.is_empty() || owner.is_empty() {
bail!("`host:<host>/<owner>` requires both segments: `{s}`");
}
if owner.contains('/') {
bail!(
"`host:` shortcut supports `host:<host>` or `host:<host>/<owner>`; \
for deeper matches use `/<regex>/`: `{s}`"
);
}
return Ok(PatternKind::HostOwner(format!("{host}/{owner}")));
}
return Ok(PatternKind::HostExact(rest.to_string()));
}
if s.len() >= 2 && s.starts_with('/') && s.ends_with('/') {
let body = &s[1..s.len() - 1];
if body.is_empty() {
bail!(
"empty regex pattern `//` would match every repo spec; \
write `/.*/` if a catch-all is intended, or remove the route"
);
}
let re = Regex::new(body).with_context(|| format!("compiling regex pattern `{s}`"))?;
return Ok(PatternKind::Regex(re));
}
bail!("unknown route pattern `{s}` — use `host:<host>`, `host:<host>/<owner>`, or `/<regex>/`")
}
#[derive(Debug, Clone)]
pub struct CloneTarget {
pub root: PathBuf,
pub layout: String,
pub default_vcs: VcsDefault,
pub default_protocol: Protocol,
pub matched_route: Option<usize>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProfileConfig {
pub root: Option<String>,
pub layout: Option<String>,
pub default_vcs: Option<VcsDefault>,
pub default_protocol: Option<Protocol>,
pub default_host: Option<String>,
pub exec_concurrency: Option<usize>,
pub git_config: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
pub root: PathBuf,
pub layout: String,
pub default_vcs: VcsDefault,
pub default_protocol: Protocol,
pub default_host: String,
pub exec_concurrency: usize,
pub ui: UiConfig,
pub shell: ShellConfig,
pub cache: CacheConfig,
pub routes: Vec<CompiledRoute>,
pub git_config: BTreeMap<String, String>,
pub active_profile: Option<String>,
pub profile_provided_root: bool,
pub raw: ShokaConfig,
}
impl ShokaConfig {
pub fn load(paths: &ShokaPaths) -> Result<Self> {
let explicit = paths.config_file();
if !explicit.exists() {
write_starter(paths).with_context(|| {
format!("auto-creating starter config at {}", explicit.display())
})?;
tracing::info!(
target: "shoka",
"wrote starter config to {} (first-run bootstrap)",
explicit.display()
);
}
let dir = paths.config_dir();
let mut files: Vec<PathBuf> = if dir.exists() {
discover_config_files(dir)
.with_context(|| format!("discovering config files in {}", dir.display()))?
} else {
Vec::new()
};
let canonical_default_name = std::ffi::OsStr::new("config.toml");
let is_user_override = explicit.file_name() != Some(canonical_default_name);
if is_user_override {
files.retain(|p| p.file_name() != Some(canonical_default_name));
}
if explicit.exists() {
let already_listed = explicit.canonicalize().is_ok_and(|explicit_can| {
files
.iter()
.any(|p| p.canonicalize().is_ok_and(|p_can| p_can == explicit_can))
});
if !already_listed {
files.insert(0, explicit.to_path_buf());
}
}
if files.is_empty() {
return Ok(Self::default());
}
let mut engine = Engine::new();
let ctx = system_context();
let merged = load_merged(files.iter(), &mut engine, &ctx)
.with_context(|| format!("loading config files in {}", dir.display()))?;
let cfg: ShokaConfig = merged
.config
.try_into()
.context("decoding merged config into ShokaConfig")?;
Ok(cfg)
}
pub fn resolve_profile_name(&self, cli: Option<&str>) -> Option<String> {
if let Some(n) = cli {
return Some(n.to_string());
}
match std::env::var("SHOKA_PROFILE") {
Ok(n) if !n.is_empty() => Some(n),
_ => self.global.default_profile.clone(),
}
}
pub fn resolve(self, cli_profile: Option<&str>) -> Result<ResolvedConfig> {
let active_profile = self.resolve_profile_name(cli_profile);
let prof = if let Some(name) = active_profile.as_deref() {
Some(
self.profiles
.get(name)
.cloned()
.ok_or_else(|| anyhow!("profile `{name}` is not defined in config"))?,
)
} else {
None
};
let prof = prof.unwrap_or_default();
let g = &self.global;
let profile_provided_root = prof.root.is_some();
let root_str = prof.root.as_deref().or(g.root.as_deref()).ok_or_else(|| {
anyhow!("`global.root` (or `profiles.<name>.root`) must be set in config")
})?;
let root = expand_home(root_str);
let routes: Vec<CompiledRoute> = self
.routes
.iter()
.enumerate()
.map(|(idx, r)| {
let pattern = compile_pattern(&r.pattern)
.with_context(|| format!("compiling routes[{idx}] pattern"))?;
let resolved_root = r.root.as_deref().map(expand_home);
Ok::<_, anyhow::Error>(CompiledRoute {
raw: r.clone(),
pattern,
resolved_root,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(ResolvedConfig {
root,
layout: prof.layout.clone().unwrap_or_else(|| g.layout.clone()),
default_vcs: prof.default_vcs.unwrap_or(g.default_vcs),
default_protocol: prof.default_protocol.unwrap_or(g.default_protocol),
default_host: prof
.default_host
.clone()
.unwrap_or_else(|| g.default_host.clone()),
exec_concurrency: prof.exec_concurrency.unwrap_or(g.exec_concurrency).max(1),
ui: g.ui.clone(),
shell: g.shell.clone(),
cache: CacheConfig {
parallel_repos: g.cache.parallel_repos.max(1),
..g.cache.clone()
},
routes,
git_config: prof.git_config.clone(),
active_profile,
profile_provided_root,
raw: self,
})
}
}
impl ResolvedConfig {
pub fn resolve_target(&self, spec: &str) -> CloneTarget {
if self.profile_provided_root {
return CloneTarget {
root: self.root.clone(),
layout: self.layout.clone(),
default_vcs: self.default_vcs,
default_protocol: self.default_protocol,
matched_route: None,
};
}
let g = &self.raw.global;
for (idx, route) in self.routes.iter().enumerate() {
if route.pattern.matches(spec) {
return CloneTarget {
root: route
.resolved_root
.clone()
.unwrap_or_else(|| self.root.clone()),
layout: route.raw.layout.clone().unwrap_or_else(|| g.layout.clone()),
default_vcs: route.raw.default_vcs.unwrap_or(g.default_vcs),
default_protocol: route.raw.default_protocol.unwrap_or(g.default_protocol),
matched_route: Some(idx),
};
}
}
CloneTarget {
root: self.root.clone(),
layout: g.layout.clone(),
default_vcs: g.default_vcs,
default_protocol: g.default_protocol,
matched_route: None,
}
}
pub fn clone_path_for(
&self,
repo: &crate::state::Repo,
engine: &mut teravars::Engine,
) -> Result<PathBuf> {
use teravars::Context;
if let Some(p) = &repo.path {
return Ok(p.clone());
}
let spec = repo.slug();
let target = self.resolve_target(&spec);
let vcs = repo.vcs.unwrap_or(target.default_vcs);
let mut ctx = Context::new();
ctx.insert("root", &target.root.to_string_lossy().to_string());
ctx.insert("host", &repo.host);
ctx.insert("owner", &repo.owner);
ctx.insert("name", &repo.name);
ctx.insert(
"profile",
&self.active_profile.as_deref().unwrap_or("default"),
);
ctx.insert("vcs", vcs_str(vcs));
ctx.insert("protocol", protocol_str(target.default_protocol));
let rendered = engine
.render(&target.layout, &ctx)
.with_context(|| format!("rendering layout `{}` for {spec}", target.layout))?;
Ok(PathBuf::from(rendered))
}
pub fn clone_path_for_one(&self, repo: &crate::state::Repo) -> Result<PathBuf> {
let mut engine = teravars::Engine::new();
self.clone_path_for(repo, &mut engine)
}
}
fn vcs_str(v: VcsDefault) -> &'static str {
match v {
VcsDefault::Auto => "auto",
VcsDefault::Git => "git",
VcsDefault::Jj => "jj",
}
}
fn protocol_str(p: Protocol) -> &'static str {
match p {
Protocol::Https => "https",
Protocol::Ssh => "ssh",
}
}
pub const STARTER_CONFIG: &str = r#"# shoka config — see https://github.com/yukimemi/shoka for the full schema.
#
# Files are layered: this `config.toml` is the base, `config.*.toml`
# siblings merge on top in alphabetical order, and `config.local.toml`
# always wins. Use `--config PATH` or $SHOKA_CONFIG to point at a
# specific file.
# [vars]
# # User-defined variables. Standard helpers home() / env(name=...) /
# # is_windows() etc. are pre-registered. Reference vars in later TOML
# # values via Tera (see docs above for syntax); the example below
# # uses home().
# work_root = "{{ home() }}/work"
[global]
# Filesystem root under which repositories are cloned. Required.
root = "~/src"
# Path layout template. Variables injected at clone time:
# root, host, owner, name, profile, vcs, protocol. Default produces
# a flat <root>/<host>/<owner>/<name> tree.
# (To set, write a Tera template string here — see the project docs.)
# default_vcs = "auto" # "auto" | "git" | "jj"
# default_protocol = "https" # "https" | "ssh"
# default_host = "github.com"
# default_profile = "personal"
# exec_concurrency = 8
# [global.ui]
# status_cache_ttl_secs = 60
# tui_refresh_ms = 250
# [global.shell]
# cd_command_name = "s"
# Routes — evaluated top-to-bottom at clone time; first hit wins.
# Pattern syntax:
# host:<host> - host exact (or `<host>/...` prefix)
# host:<host>/<owner> - host + owner prefix
# /<regex>/ - full regex (Rust regex crate)
# A matching route's `root` / `layout` / `default_vcs` / `default_protocol`
# override the corresponding [global] fields. Absent fields fall through.
# Precedence: profile.root (when set) > routes > [global].root.
#
# [[routes]]
# pattern = "host:github.com/mycompany"
# root = "~/src/work"
#
# [[routes]]
# pattern = "host:gitlab.com"
# root = "~/src/gitlab"
# default_protocol = "ssh"
# Profiles — `--profile NAME` / $SHOKA_PROFILE / global.default_profile
# selects one. Profile fields override [global] when set; absent fields
# fall through to the global value.
# [profiles.personal]
# root = "~/src"
#
# [profiles.work]
# root = "~/work"
# default_host = "github.com"
#
# [profiles.work.git_config]
# "user.email" = "work@example.com"
"#;
pub fn write_starter(paths: &ShokaPaths) -> Result<()> {
let target = paths.config_file();
if target.exists() {
anyhow::bail!(
"config file already exists at {}; not overwriting",
target.display()
);
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating config dir {}", parent.display()))?;
}
std::fs::write(target, STARTER_CONFIG)
.with_context(|| format!("writing starter config to {}", target.display()))?;
Ok(())
}
fn expand_home(s: &str) -> PathBuf {
let expanded = expand_tilde(s);
std::path::absolute(&expanded).unwrap_or(expanded)
}
fn expand_tilde(s: &str) -> PathBuf {
let is_bare = s == "~";
let is_rooted = s.starts_with("~/") || s.starts_with("~\\");
if is_bare || is_rooted {
if let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) {
if is_bare {
return home;
}
return home.join(&s[2..]);
}
}
PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn paths_at(dir: &Path) -> ShokaPaths {
ShokaPaths::resolve(Some(&dir.join("config.toml")))
.expect("ShokaPaths::resolve with override")
}
#[test]
fn load_auto_writes_starter_when_missing() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("config.toml");
assert!(!target.exists(), "preconditions: target must not exist yet");
let cfg =
ShokaConfig::load(&paths_at(tmp.path())).expect("load should auto-create starter");
assert!(
target.exists(),
"load must have written the starter at {target:?}"
);
assert_eq!(cfg.global.root.as_deref(), Some("~/src"));
assert!(
cfg.profiles.is_empty(),
"starter does not define any profiles"
);
}
#[test]
fn load_does_not_overwrite_existing_config() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/user/edited"
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/user/edited"));
let body = fs::read_to_string(tmp.path().join("config.toml")).unwrap();
assert!(body.contains("/user/edited"));
assert!(
!body.contains("shoka config — see"),
"starter header should not have been written over user content"
);
}
#[test]
fn single_file_load_parses_root_and_default_host() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/tmp/repos"
default_host = "gitlab.com"
exec_concurrency = 16
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/tmp/repos"));
assert_eq!(cfg.global.default_host, "gitlab.com");
assert_eq!(cfg.global.exec_concurrency, 16);
}
#[test]
fn layered_local_override_wins() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/base/repos"
default_host = "github.com"
exec_concurrency = 4
"#,
)
.unwrap();
fs::write(
tmp.path().join("config.local.toml"),
r#"
[global]
exec_concurrency = 32
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.global.exec_concurrency, 32);
assert_eq!(cfg.global.root.as_deref(), Some("/base/repos"));
assert_eq!(cfg.global.default_host, "github.com");
}
#[test]
fn vars_self_reference_resolves() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[vars]
base = "/data"
repo_root = "{{ vars.base }}/repos"
[global]
root = "{{ vars.repo_root }}"
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/data/repos"));
}
#[test]
fn compile_pattern_host_exact() {
let p = compile_pattern("host:github.com").unwrap();
assert!(p.matches("github.com"));
assert!(p.matches("github.com/foo"));
assert!(p.matches("github.com/foo/bar"));
assert!(!p.matches("gitlab.com"));
assert!(!p.matches("github.com.evil"));
}
#[test]
fn compile_pattern_host_owner() {
let p = compile_pattern("host:github.com/yukimemi").unwrap();
assert!(p.matches("github.com/yukimemi"));
assert!(p.matches("github.com/yukimemi/shoka"));
assert!(!p.matches("github.com/other"));
assert!(!p.matches("github.com/yukimemi-other"));
assert!(!p.matches("github.com"));
}
#[test]
fn compile_pattern_regex() {
let p = compile_pattern(r"/^github\.com/foo-.*$/").unwrap();
assert!(p.matches("github.com/foo-bar"));
assert!(p.matches("github.com/foo-bar/baz"));
assert!(!p.matches("github.com/bar"));
}
#[test]
fn compile_pattern_rejects_garbage() {
for bad in [
"github.com", "host:", "host:/", "host:foo/", "host:/bar", "/", "//", "host:foo/bar/baz", ] {
assert!(
compile_pattern(bad).is_err(),
"pattern `{bad}` should fail to compile"
);
}
}
#[test]
fn compile_pattern_explicit_catchall_regex_works() {
let p = compile_pattern("/.*/").unwrap();
assert!(p.matches("github.com/foo/bar"));
assert!(p.matches(""));
}
#[test]
fn compile_pattern_rejects_invalid_regex() {
let err = compile_pattern("/[/").unwrap_err();
assert!(
err.to_string().contains("regex"),
"error should mention regex: {err}"
);
}
fn route(pattern: &str, root: &str) -> Route {
Route {
pattern: pattern.into(),
root: Some(root.into()),
layout: None,
default_vcs: None,
default_protocol: None,
}
}
fn resolved_with(routes: Vec<Route>) -> ResolvedConfig {
ShokaConfig {
global: GlobalConfig {
root: Some("/global".into()),
..Default::default()
},
routes,
..Default::default()
}
.resolve(None)
.expect("resolve")
}
#[test]
fn resolve_target_falls_back_to_global_when_no_route_matches() {
let r = resolved_with(vec![route("host:gitlab.com", "/elsewhere")]);
let t = r.resolve_target("github.com/foo/bar");
assert!(t.matched_route.is_none());
assert!(t.root.ends_with("global"));
}
#[test]
fn resolve_target_first_match_wins() {
let r = resolved_with(vec![
route("host:github.com/yukimemi", "/personal"),
route("host:github.com", "/github-catchall"),
]);
let t = r.resolve_target("github.com/yukimemi/shoka");
assert_eq!(t.matched_route, Some(0));
assert!(t.root.ends_with("personal"));
}
#[test]
fn resolve_target_route_overrides_pass_through_unset_fields() {
let r = ShokaConfig {
global: GlobalConfig {
root: Some("/global".into()),
layout: "{{ root }}/L".into(),
..Default::default()
},
routes: vec![Route {
pattern: "host:github.com".into(),
root: Some("/gh".into()),
layout: None, default_vcs: Some(VcsDefault::Jj),
default_protocol: None,
}],
..Default::default()
}
.resolve(None)
.expect("resolve");
let t = r.resolve_target("github.com/foo/bar");
assert!(t.root.ends_with("gh"));
assert_eq!(t.layout, "{{ root }}/L"); assert_eq!(t.default_vcs, VcsDefault::Jj);
assert_eq!(t.default_protocol, Protocol::Https); }
#[test]
fn resolve_target_profile_root_beats_routes() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/global".into()),
..Default::default()
},
routes: vec![route("host:github.com", "/routed")],
profiles: BTreeMap::from([(
"work".into(),
ProfileConfig {
root: Some("/work".into()),
..Default::default()
},
)]),
};
let r = cfg.resolve(Some("work")).expect("resolve");
assert!(r.profile_provided_root);
let t = r.resolve_target("github.com/foo/bar");
assert!(
t.matched_route.is_none(),
"profile.root should skip route matching, got {:?}",
t.matched_route
);
assert!(t.root.ends_with("work"));
}
#[test]
fn resolve_target_routes_run_when_profile_lacks_root() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/global".into()),
..Default::default()
},
routes: vec![route("host:github.com", "/routed")],
profiles: BTreeMap::from([(
"vcs-only".into(),
ProfileConfig {
default_vcs: Some(VcsDefault::Jj),
..Default::default()
},
)]),
};
let r = cfg.resolve(Some("vcs-only")).expect("resolve");
assert!(!r.profile_provided_root);
let t = r.resolve_target("github.com/foo/bar");
assert_eq!(t.matched_route, Some(0));
assert!(t.root.ends_with("routed"));
assert_eq!(
t.default_vcs,
VcsDefault::Auto,
"profile vcs should not leak into routes when profile.root isn't pinned"
);
}
#[test]
fn resolve_target_no_match_uses_raw_global_not_profile_overlay() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/global".into()),
default_vcs: VcsDefault::Auto,
..Default::default()
},
routes: vec![],
profiles: BTreeMap::from([(
"vcs-only".into(),
ProfileConfig {
default_vcs: Some(VcsDefault::Jj),
..Default::default()
},
)]),
};
let r = cfg.resolve(Some("vcs-only")).expect("resolve");
let t = r.resolve_target("github.com/foo/bar");
assert!(t.matched_route.is_none());
assert!(t.root.ends_with("global"));
assert_eq!(
t.default_vcs,
VcsDefault::Auto,
"profile.default_vcs must not apply when profile didn't pin a root"
);
}
#[test]
fn resolve_errors_on_bad_route_pattern() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/r".into()),
..Default::default()
},
routes: vec![Route {
pattern: "not-a-valid-pattern".into(),
root: None,
layout: None,
default_vcs: None,
default_protocol: None,
}],
..Default::default()
};
let err = cfg.resolve(None).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("routes[0]") && msg.contains("unknown route pattern"),
"error should locate the bad pattern: {msg}"
);
}
#[test]
fn routes_round_trip_through_toml_load() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/global"
[[routes]]
pattern = "host:github.com/mycompany"
root = "/work"
[[routes]]
pattern = "host:gitlab.com"
root = "/gitlab"
default_protocol = "ssh"
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.routes.len(), 2);
let r = cfg.resolve(None).expect("resolve");
let t1 = r.resolve_target("github.com/mycompany/internal-tool");
assert_eq!(t1.matched_route, Some(0));
assert!(t1.root.ends_with("work"));
let t2 = r.resolve_target("gitlab.com/anyone/anything");
assert_eq!(t2.matched_route, Some(1));
assert_eq!(t2.default_protocol, Protocol::Ssh);
let t3 = r.resolve_target("github.com/other/repo");
assert!(t3.matched_route.is_none());
}
#[test]
fn profile_overrides_global_root() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/personal"
default_profile = "work"
[profiles.work]
root = "/work"
exec_concurrency = 32
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
let resolved = cfg.resolve(None).expect("resolve");
assert_eq!(resolved.active_profile.as_deref(), Some("work"));
assert!(
resolved.root.is_absolute(),
"resolved root must be absolute, got {:?}",
resolved.root
);
assert!(
resolved.root.ends_with("work"),
"resolved root should end with `work`, got {:?}",
resolved.root
);
assert_eq!(resolved.exec_concurrency, 32);
}
#[test]
fn cli_profile_overrides_default_profile() {
let cfg = ShokaConfig {
global: GlobalConfig {
default_profile: Some("personal".into()),
..Default::default()
},
profiles: BTreeMap::from([
("personal".into(), ProfileConfig::default()),
("work".into(), ProfileConfig::default()),
]),
..Default::default()
};
assert_eq!(cfg.resolve_profile_name(Some("work")), Some("work".into()));
assert_eq!(cfg.resolve_profile_name(None), Some("personal".into()));
}
#[test]
fn resolve_errors_when_profile_is_undefined() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/r".into()),
..Default::default()
},
..Default::default()
};
let err = cfg.resolve(Some("ghost")).unwrap_err();
assert!(
err.to_string().contains("ghost"),
"error should mention the missing profile name: {err}"
);
}
#[test]
fn resolve_errors_when_root_is_unset() {
let cfg = ShokaConfig::default();
let err = cfg.resolve(None).unwrap_err();
assert!(
err.to_string().contains("root"),
"error should mention the missing root: {err}"
);
}
#[test]
fn expand_home_handles_leading_tilde() {
let p = expand_home("~/src/repos");
assert!(
p.is_absolute(),
"expected ~ expansion to produce an absolute path, got {p:?}"
);
assert!(p.to_string_lossy().contains("src"));
}
#[test]
fn expand_home_keeps_already_absolute_inputs_absolute() {
let abs = std::env::temp_dir();
let expanded = expand_home(abs.to_str().unwrap());
assert!(
expanded.is_absolute(),
"absolute input must stay absolute, got {expanded:?}"
);
}
#[test]
fn expand_home_makes_relative_input_absolute() {
let expanded = expand_home("relative/repos");
assert!(
expanded.is_absolute(),
"relative input should be absolutised, got {expanded:?}"
);
assert!(
expanded.ends_with("relative/repos") || expanded.ends_with("relative\\repos"),
"absolutised path should still end with the original tail, got {expanded:?}"
);
}
#[test]
fn expand_home_bare_tilde_yields_home() {
let p = expand_home("~");
assert!(p.is_absolute(), "bare ~ should expand to home, got {p:?}");
}
#[test]
fn expand_home_does_not_touch_tilde_user_syntax() {
let p1 = expand_home("~foo");
assert!(p1.is_absolute(), "result should be absolute, got {p1:?}");
assert!(
p1.to_string_lossy().contains("~foo"),
"~foo literal should be preserved in the result, got {p1:?}"
);
let p2 = expand_home("~bar/baz");
assert!(p2.is_absolute(), "result should be absolute, got {p2:?}");
assert!(
p2.to_string_lossy().contains("~bar"),
"~bar literal should be preserved, got {p2:?}"
);
}
#[test]
fn override_with_custom_filename_is_loaded() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("custom.toml"),
r#"
[global]
root = "/from/custom"
default_host = "gitlab.com"
"#,
)
.unwrap();
let paths = ShokaPaths::resolve(Some(&tmp.path().join("custom.toml")))
.expect("ShokaPaths::resolve");
let cfg = ShokaConfig::load(&paths).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/from/custom"));
assert_eq!(cfg.global.default_host, "gitlab.com");
}
#[test]
fn write_starter_creates_file_when_missing() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("nested").join("config.toml");
let paths = ShokaPaths::resolve(Some(&target)).unwrap();
assert!(!target.exists());
write_starter(&paths).expect("write_starter on fresh path");
assert!(target.exists(), "starter file should exist at {target:?}");
let body = fs::read_to_string(&target).unwrap();
assert!(
body.contains("[global]"),
"starter should contain [global] section, got: {body}"
);
assert!(
body.contains("root ="),
"starter should set root = ..., got: {body}"
);
}
#[test]
fn write_starter_refuses_to_overwrite() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("config.toml");
fs::write(&target, "# existing user config\n").unwrap();
let paths = ShokaPaths::resolve(Some(&target)).unwrap();
let err = write_starter(&paths).expect_err("write_starter must refuse overwrite");
assert!(
err.to_string().contains("already exists"),
"error should mention existing file: {err}"
);
let body = fs::read_to_string(&target).unwrap();
assert_eq!(body, "# existing user config\n");
}
#[test]
fn starter_content_is_loadable() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("config.toml");
let paths = ShokaPaths::resolve(Some(&target)).unwrap();
write_starter(&paths).expect("write");
let cfg = ShokaConfig::load(&paths).expect("load starter");
assert_eq!(cfg.global.root.as_deref(), Some("~/src"));
assert_eq!(cfg.global.default_vcs, VcsDefault::Auto);
assert_eq!(cfg.global.default_host, "github.com");
}
#[test]
fn user_named_override_displaces_sibling_default_config_toml() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/from/default"
"#,
)
.unwrap();
fs::write(
tmp.path().join("staging.toml"),
r#"
[global]
root = "/from/staging"
"#,
)
.unwrap();
let paths = ShokaPaths::resolve(Some(&tmp.path().join("staging.toml")))
.expect("ShokaPaths::resolve");
let cfg = ShokaConfig::load(&paths).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/from/staging"));
}
#[test]
fn override_does_not_duplicate_when_file_matches_canonical_name() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/once"
"#,
)
.unwrap();
let cfg = ShokaConfig::load(&paths_at(tmp.path())).expect("load");
assert_eq!(cfg.global.root.as_deref(), Some("/once"));
}
}