use crate::paths::Paths;
use anyhow::{Context, Result};
use std::fs;
pub const DEFAULT_CONFIG: &str = r#"{
"default": "pi",
"runners": {
"claude": {
"tick": "claude -p --output-format stream-json --verbose --dangerously-skip-permissions",
"interactive": "claude \"$(cat {{prompt_file}})\"",
"resume": "claude --resume"
},
"pi": {
"tick": "pi -p --mode json -ne --model claude-opus-4-8 --thinking low 'Execute the looop tick instructions provided on stdin.'",
"interactive": "pi --model claude-opus-4-8 --thinking medium @{{prompt_file}}",
"resume": "pi --session"
}
}
}
"#;
#[derive(Debug, Clone, PartialEq)]
pub struct CostSpec {
pub type_tag: String,
pub pointer: String,
pub mode: CostMode,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CostMode {
Sum,
Total,
}
pub struct Config {
pub root: serde_json::Value,
}
impl Config {
pub fn load(paths: &Paths) -> Result<Self> {
let text = if paths.config.is_file() {
fs::read_to_string(&paths.config)
.with_context(|| format!("reading config {}", paths.config.display()))?
} else {
DEFAULT_CONFIG.to_string()
};
let root: serde_json::Value =
serde_json::from_str(&text).context("parsing looop config JSON")?;
Ok(Config { root })
}
pub fn notification(&self) -> Option<String> {
self.root
.get("notification")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
pub fn default_runner(&self) -> Option<String> {
self.root
.get("default")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
pub fn runner_cmd(&self, name: &str, key: &str) -> Option<String> {
self.root
.get("runners")?
.get(name)?
.get(key)?
.as_str()
.map(strip_fmt_seam)
}
pub fn active_runner_cmd(&self, key: &str) -> Option<String> {
let name = self.default_runner()?;
self.runner_cmd(&name, key)
}
pub fn runner_cost_spec(&self, name: &str) -> Option<CostSpec> {
let c = self.root.get("runners")?.get(name)?.get("cost")?;
let type_tag = c.get("type")?.as_str()?.to_string();
let pointer = c.get("pointer")?.as_str()?.to_string();
let mode = match c.get("mode").and_then(|m| m.as_str()).unwrap_or("sum") {
"total" => CostMode::Total,
_ => CostMode::Sum,
};
Some(CostSpec {
type_tag,
pointer,
mode,
})
}
}
fn strip_fmt_seam(cmd: &str) -> String {
if let Some(idx) = cmd.rfind('|') {
let tail: String = cmd[idx + 1..]
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let is_fmt = (tail.ends_with("_ fmt") || tail.ends_with("_fmt"))
&& (tail.contains("LOOOP_BIN") || tail.contains("looop"));
if is_fmt {
return cmd[..idx].trim_end().to_string();
}
}
cmd.to_string()
}
pub fn ensure_config(paths: &Paths) -> Result<()> {
if !paths.config.is_file() {
if let Some(dir) = paths.config.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("creating config dir {}", dir.display()))?;
}
fs::write(&paths.config, DEFAULT_CONFIG)
.with_context(|| format!("seeding config {}", paths.config.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::strip_fmt_seam;
#[test]
fn strips_trailing_fmt_seam() {
assert_eq!(
strip_fmt_seam("pi -p --mode json | \"$LOOOP_BIN\" _ fmt"),
"pi -p --mode json"
);
assert_eq!(strip_fmt_seam("claude -p | looop _fmt"), "claude -p");
}
#[test]
fn leaves_unrelated_pipelines_untouched() {
let cmd = "pi -p --mode json | jq .";
assert_eq!(strip_fmt_seam(cmd), cmd);
let plain = "pi -p --mode json";
assert_eq!(strip_fmt_seam(plain), plain);
let other = "claude -p | tee out.log";
assert_eq!(strip_fmt_seam(other), other);
}
#[test]
fn default_config_has_no_fmt_seam() {
assert!(!super::DEFAULT_CONFIG.contains("_ fmt"));
assert!(!super::DEFAULT_CONFIG.contains("_fmt"));
}
}