use std::collections::HashMap;
use serde::Deserialize;
use crate::shell::config_dir;
#[derive(Deserialize, Default)]
pub struct ShannonConfig {
pub toggle: Option<Vec<String>>,
pub default_shell: Option<String>,
#[serde(default)]
pub shells: HashMap<String, ShellConfig>,
#[serde(default)]
pub ai: AiConfig,
#[serde(default)]
pub theme: ThemeConfig,
}
#[derive(Deserialize, Default, Clone)]
pub struct AiConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub api_key_env: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct ThemeConfig {
pub name: Option<String>,
pub keyword: Option<String>,
pub command: Option<String>,
pub string: Option<String>,
pub number: Option<String>,
pub variable: Option<String>,
pub operator: Option<String>,
pub comment: Option<String>,
pub error: Option<String>,
pub foreground: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub prompt: Option<String>,
pub hint: Option<String>,
}
#[derive(Deserialize, Clone)]
pub struct ShellConfig {
pub binary: String,
pub wrapper: String,
#[serde(default = "default_parser")]
pub parser: String,
pub highlighter: Option<String>,
pub init: Option<String>,
}
fn default_parser() -> String {
"env".to_string()
}
const BASH_WRAPPER: &str = r#"{{init}}
{{command}}
__shannon_ec=$?
(export -p; echo "__SHANNON_CWD=$(pwd)"; echo "__SHANNON_EXIT=$__shannon_ec") > '{{temp_path}}'
exit $__shannon_ec"#;
const ENV_WRAPPER: &str = r#"{{init}}
{{command}}
__shannon_ec=$?
env > '{{temp_path}}'
echo "__SHANNON_CWD=$(pwd)" >> '{{temp_path}}'
echo "__SHANNON_EXIT=$__shannon_ec" >> '{{temp_path}}'
exit $__shannon_ec"#;
const FISH_WRAPPER: &str = r#"{{init}}
{{command}}
set __shannon_ec $status
env > '{{temp_path}}'
echo "__SHANNON_CWD="(pwd) >> '{{temp_path}}'
echo "__SHANNON_EXIT=$__shannon_ec" >> '{{temp_path}}'
exit $__shannon_ec"#;
fn builtin_shells() -> Vec<(String, ShellConfig)> {
vec![
(
"bash".to_string(),
ShellConfig {
binary: "bash".to_string(),
wrapper: BASH_WRAPPER.to_string(),
parser: "bash".to_string(),
highlighter: Some("bash".to_string()),
init: None,
},
),
(
"fish".to_string(),
ShellConfig {
binary: "fish".to_string(),
wrapper: FISH_WRAPPER.to_string(),
parser: "env".to_string(),
highlighter: Some("fish".to_string()),
init: None,
},
),
(
"zsh".to_string(),
ShellConfig {
binary: "zsh".to_string(),
wrapper: ENV_WRAPPER.to_string(),
parser: "env".to_string(),
highlighter: Some("bash".to_string()),
init: None,
},
),
]
}
fn nushell_config() -> ShellConfig {
ShellConfig {
binary: String::new(), wrapper: String::new(), parser: String::new(), highlighter: Some("nushell".to_string()),
init: None,
}
}
fn all_shells(config: &ShannonConfig) -> HashMap<String, ShellConfig> {
let mut map = HashMap::new();
map.insert("nu".to_string(), nushell_config());
for (name, shell_config) in builtin_shells() {
map.insert(name, shell_config);
}
for (name, shell_config) in &config.shells {
map.insert(name.clone(), shell_config.clone());
}
map
}
impl ShannonConfig {
pub fn load() -> Self {
let config_path = config_dir().join("config.toml");
if !config_path.exists() {
return ShannonConfig::default();
}
let contents = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("shannon: failed to read config.toml: {e}");
std::process::exit(1);
}
};
match toml::from_str(&contents) {
Ok(config) => config,
Err(e) => {
eprintln!("shannon: invalid config.toml: {e}");
std::process::exit(1);
}
}
}
pub fn shells(&self) -> Vec<(String, ShellConfig)> {
let available = all_shells(self);
if let Some(toggle) = &self.toggle {
let mut result = Vec::new();
for name in toggle {
if let Some(config) = available.get(name) {
result.push((name.clone(), config.clone()));
} else {
eprintln!("shannon: unknown shell in toggle list: {name}");
}
}
return result;
}
let mut result = Vec::new();
let default_order = ["bash", "nu", "fish", "zsh"];
for name in default_order {
if let Some(config) = available.get(name) {
result.push((name.to_string(), config.clone()));
}
}
for (name, config) in &self.shells {
if !result.iter().any(|(n, _)| n == name) {
result.push((name.clone(), config.clone()));
}
}
if let Some(default_name) = &self.default_shell {
if let Some(pos) = result.iter().position(|(n, _)| n == default_name) {
let shell = result.remove(pos);
result.insert(0, shell);
}
}
result
}
}
pub fn expand_wrapper(
wrapper: &str,
command: &str,
temp_path: &str,
init_content: &str,
) -> String {
wrapper
.replace("{{command}}", command)
.replace("{{temp_path}}", temp_path)
.replace("{{init}}", init_content)
}
pub fn read_init_file(init_path: Option<&str>) -> String {
let path = match init_path {
Some(p) => config_dir().join(p),
None => return String::new(),
};
std::fs::read_to_string(&path).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_config() {
let config = ShannonConfig::default();
let shells = config.shells();
assert_eq!(shells.len(), 4);
assert_eq!(shells[0].0, "bash");
assert_eq!(shells[1].0, "nu");
assert_eq!(shells[2].0, "fish");
assert_eq!(shells[3].0, "zsh");
}
#[test]
fn test_toggle_list() {
let config: ShannonConfig = toml::from_str(r#"toggle = ["nu", "bash"]"#).unwrap();
let shells = config.shells();
assert_eq!(shells.len(), 2);
assert_eq!(shells[0].0, "nu");
assert_eq!(shells[1].0, "bash");
}
#[test]
fn test_toggle_unknown_shell() {
let config: ShannonConfig =
toml::from_str(r#"toggle = ["nu", "nonexistent", "bash"]"#).unwrap();
let shells = config.shells();
assert_eq!(shells.len(), 2);
assert_eq!(shells[0].0, "nu");
assert_eq!(shells[1].0, "bash");
}
#[test]
fn test_toggle_duplicates() {
let config: ShannonConfig =
toml::from_str(r#"toggle = ["fish", "bash", "fish"]"#).unwrap();
let shells = config.shells();
assert_eq!(shells.len(), 3);
assert_eq!(shells[0].0, "fish");
assert_eq!(shells[1].0, "bash");
assert_eq!(shells[2].0, "fish");
}
#[test]
fn test_toggle_with_custom_shell() {
let toml_str = r#"
toggle = ["zsh", "nu"]
[shells.elvish]
binary = "elvish"
wrapper = "{{command}}"
"#;
let config: ShannonConfig = toml::from_str(toml_str).unwrap();
let shells = config.shells();
assert_eq!(shells.len(), 2);
assert_eq!(shells[0].0, "zsh");
assert_eq!(shells[1].0, "nu");
}
#[test]
fn test_default_shell_backward_compat() {
let config: ShannonConfig =
toml::from_str(r#"default_shell = "nu""#).unwrap();
let shells = config.shells();
assert_eq!(shells[0].0, "nu");
assert_eq!(shells.len(), 4); }
#[test]
fn test_custom_shell_in_toggle() {
let toml_str = r#"
toggle = ["elvish", "bash"]
[shells.elvish]
binary = "elvish"
wrapper = "{{command}}"
"#;
let config: ShannonConfig = toml::from_str(toml_str).unwrap();
let shells = config.shells();
assert_eq!(shells.len(), 2);
assert_eq!(shells[0].0, "elvish");
assert_eq!(shells[0].1.binary, "elvish");
assert_eq!(shells[1].0, "bash");
}
#[test]
fn test_override_builtin() {
let toml_str = r#"
[shells.bash]
binary = "/custom/bash"
wrapper = "custom {{command}}"
parser = "bash"
highlighter = "bash"
"#;
let config: ShannonConfig = toml::from_str(toml_str).unwrap();
let shells = config.shells();
assert_eq!(shells[0].1.binary, "/custom/bash");
}
#[test]
fn test_expand_wrapper() {
let result = expand_wrapper(
"{{init}}\n{{command}}\nenv > '{{temp_path}}'",
"echo hello",
"/tmp/test.env",
"# init",
);
assert!(result.contains("echo hello"));
assert!(result.contains("/tmp/test.env"));
assert!(result.contains("# init"));
}
#[test]
fn test_expand_wrapper_empty_init() {
let result = expand_wrapper("{{init}}{{command}}", "ls", "/tmp/t", "");
assert_eq!(result, "ls");
}
#[test]
fn test_toml_parse_toggle() {
let toml_str = r#"
toggle = ["nu", "fish"]
[shells.zsh]
binary = "zsh"
wrapper = "{{command}}"
parser = "env"
highlighter = "bash"
"#;
let config: ShannonConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.toggle.as_deref(), Some(&["nu".to_string(), "fish".to_string()][..]));
assert!(config.shells.contains_key("zsh"));
}
}