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"
}
}
}
"#;
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 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)
}
}
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"));
}
}