LCOV - code coverage report
Current view: top level - dashql-cli/src/agent - config.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 97.8 % 274 268
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! The configuration of the agent server, ~/.dashql/agent/config.json
       2              : 
       3              : use serde_json::{json, Map, Value};
       4              : use std::path::{Path, PathBuf};
       5              : use std::time::Duration;
       6              : 
       7              : /// The default port of the agent server
       8              : pub const DEFAULT_PORT: u16 = 47100;
       9              : 
      10              : /// The origins of DashQL clients: the web app and desktop windows
      11              : const RELEASE_ORIGINS: [&str; 3] = ["https://dashql.app", "https://stable.dashql.app", "app://bundle"];
      12              : 
      13              : /// The origins of the dev server of the web app. Only development builds accept them: in a release, any page on
      14              : /// that port, e.g. the dev server of another project, would be a DashQL client. A release that should accept them
      15              : /// lists them in `origins` of config.json.
      16              : const DEV_ORIGINS: [&str; 2] = ["http://localhost:9002", "http://127.0.0.1:9002"];
      17              : 
      18              : /// The origins of DashQL clients that this build accepts, release builds (`--config=release`) set `dashql_release`
      19           28 : pub fn client_origins() -> Vec<&'static str> {
      20           28 :     let dev: &[&str] = if cfg!(dashql_release) { &[] } else { &DEV_ORIGINS };
      21           28 :     RELEASE_ORIGINS.iter().chain(dev).copied().collect()
      22           28 : }
      23              : 
      24              : /// How long a call waits for DashQL to hold its session, unless `callWaitMs` says otherwise
      25              : const DEFAULT_CALL_WAIT: Duration = Duration::from_secs(30);
      26              : 
      27              : /// How the server reads the event stream of a harness
      28              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      29              : pub enum HarnessAdapter {
      30              :     Codex,
      31              :     Claude,
      32              :     Opencode,
      33              :     Text,
      34              : }
      35              : 
      36              : impl HarnessAdapter {
      37            9 :     pub fn name(self) -> &'static str {
      38            9 :         match self {
      39            3 :             HarnessAdapter::Codex => "codex",
      40            3 :             HarnessAdapter::Claude => "claude",
      41            3 :             HarnessAdapter::Opencode => "opencode",
      42            0 :             HarnessAdapter::Text => "text",
      43              :         }
      44            9 :     }
      45              : 
      46              :     /// Unknown adapters read plain text
      47          139 :     fn parse(name: &str) -> Self {
      48          139 :         match name {
      49          139 :             "codex" => HarnessAdapter::Codex,
      50           95 :             "claude" => HarnessAdapter::Claude,
      51           49 :             "opencode" => HarnessAdapter::Opencode,
      52            3 :             _ => HarnessAdapter::Text,
      53              :         }
      54          139 :     }
      55              : }
      56              : 
      57              : /// A setting of harness runs that requests may pick, the model or the effort
      58              : #[derive(Clone, Debug, Default, PartialEq, Eq)]
      59              : pub struct HarnessChoice {
      60              :     /// The values that requests may pick, no request can pass another value
      61              :     pub values: Vec<String>,
      62              :     /// The arguments that pass a picked value
      63              :     pub args: Vec<String>,
      64              : }
      65              : 
      66              : /// How the agent server runs a harness headless.
      67              : ///
      68              : /// Arguments are passed to the process as they are, never through a shell.
      69              : /// `{prompt}` is replaced by the prompt, `{resume}` by `resumeArgs` once the DashQL session has a harness
      70              : /// session, and is dropped otherwise. `{harnessSession}` in `resumeArgs` is the stored harness session id.
      71              : /// `{model}` and `{effort}` are replaced by `modelArgs` and `effortArgs` once a request picks one of `models` or
      72              : /// `efforts`, and are dropped otherwise. In `modelArgs` and `effortArgs`, they are the picked value.
      73              : #[derive(Clone, Debug, PartialEq, Eq)]
      74              : pub struct HarnessConfig {
      75              :     /// The program and its arguments
      76              :     pub command: Vec<String>,
      77              :     /// The arguments that resume a harness session
      78              :     pub resume_args: Vec<String>,
      79              :     /// The models that requests may pick
      80              :     pub model: HarnessChoice,
      81              :     /// The efforts that requests may pick
      82              :     pub effort: HarnessChoice,
      83              :     /// The command that continues the harness session in a terminal, shown by the app
      84              :     pub terminal_command: Option<Vec<String>>,
      85              :     /// How the output is read
      86              :     pub adapter: HarnessAdapter,
      87              : }
      88              : 
      89              : /// The configuration of the agent server
      90              : #[derive(Clone, Debug)]
      91              : pub struct AgentConfig {
      92              :     /// The port on 127.0.0.1
      93              :     pub port: u16,
      94              :     /// The token of the command line tool
      95              :     pub token: String,
      96              :     /// The harness that runs requests without a harness name
      97              :     pub default_harness: String,
      98              :     /// The harnesses by name, the built-in ones first
      99              :     pub harnesses: Vec<(String, HarnessConfig)>,
     100              :     /// Additional origins that the server accepts
     101              :     pub origins: Vec<String>,
     102              :     /// How long a call waits for DashQL to hold its session
     103              :     pub call_wait: Duration,
     104              : }
     105              : 
     106              : impl AgentConfig {
     107           84 :     pub fn harness(&self, name: &str) -> Option<&HarnessConfig> {
     108          170 :         self.harnesses.iter().find(|(n, _)| n == name).map(|(_, h)| h)
     109           84 :     }
     110              : }
     111              : 
     112              : /// The built-in harnesses. config.json can override them by name and add its own.
     113              : /// `--` ends the options before the prompt: Claude Code's tool lists take every following argument, and a prompt
     114              : /// starting with `-` would be read as an option.
     115              : /// Run against Claude Code 2.1, opencode 1.18 and Codex 0.162.
     116              : /// Claude Code denies heredocs with JSON, so its runs may write the inputs of `--file` into the input folder.
     117              : /// The models of Claude Code are aliases of its latest models. Codex and opencode name their models by provider,
     118              : /// config.json lists the ones to pick.
     119           46 : fn default_harnesses() -> Value {
     120           46 :     json!({
     121           46 :         "claude": {
     122           46 :             "command": [
     123              :                 "claude", "-p", "--output-format", "stream-json", "--verbose",
     124              :                 "--allowedTools", "Bash(dashql agent:*)", "Edit(./input/*)",
     125              :                 "--disallowedTools", "Bash(dashql agent serve:*)",
     126              :                 "{model}", "{effort}", "{resume}", "--", "{prompt}",
     127              :             ],
     128           46 :             "resumeArgs": ["--resume", "{harnessSession}"],
     129           46 :             "models": ["fable", "opus", "sonnet", "haiku"],
     130           46 :             "modelArgs": ["--model", "{model}"],
     131           46 :             "efforts": ["low", "medium", "high", "xhigh", "max"],
     132           46 :             "effortArgs": ["--effort", "{effort}"],
     133           46 :             "terminalCommand": ["claude", "--resume", "{harnessSession}"],
     134           46 :             "adapter": "claude",
     135              :         },
     136              :         // Codex sandboxes the shell of a run without network, so `dashql agent` could not reach the agent server on
     137              :         // 127.0.0.1. Codex cannot allow only loopback, so runs get the workspace-write sandbox with network access.
     138           46 :         "codex": {
     139           46 :             "command": [
     140              :                 "codex", "exec", "--json", "--skip-git-repo-check",
     141              :                 "--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true",
     142              :                 "{model}", "{effort}", "{resume}", "--", "{prompt}",
     143              :             ],
     144           46 :             "resumeArgs": ["resume", "{harnessSession}"],
     145           46 :             "modelArgs": ["-m", "{model}"],
     146           46 :             "efforts": ["low", "medium", "high"],
     147           46 :             "effortArgs": ["-c", "model_reasoning_effort={effort}"],
     148           46 :             "terminalCommand": ["codex", "resume", "{harnessSession}"],
     149           46 :             "adapter": "codex",
     150              :         },
     151              :         // The permissions of opencode runs come from the opencode.json of the working folder
     152           46 :         "opencode": {
     153           46 :             "command": ["opencode", "run", "--format", "json", "{model}", "{resume}", "--", "{prompt}"],
     154           46 :             "resumeArgs": ["--session", "{harnessSession}"],
     155           46 :             "modelArgs": ["--model", "{model}"],
     156           46 :             "terminalCommand": ["opencode", "--session", "{harnessSession}"],
     157           46 :             "adapter": "opencode",
     158              :         },
     159              :     })
     160           46 : }
     161              : 
     162              : /// The folder of the configuration and the state of the agent server
     163            2 : pub fn agent_home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
     164            2 :     if let Some(home) = env("DASHQL_AGENT_HOME").filter(|h| !h.is_empty()) {
     165            1 :         return home.into();
     166            1 :     }
     167            1 :     let user_home = env("HOME").or_else(|| env("USERPROFILE")).unwrap_or_default();
     168            1 :     PathBuf::from(user_home).join(".dashql").join("agent")
     169            2 : }
     170              : 
     171              : /// Random bytes from the operating system
     172          129 : pub fn random_bytes<const N: usize>() -> [u8; N] {
     173          129 :     let mut bytes = [0; N];
     174          129 :     getrandom::fill(&mut bytes).expect("the operating system has no random numbers");
     175          129 :     bytes
     176          129 : }
     177              : 
     178           83 : fn new_token() -> String {
     179         1992 :     random_bytes::<24>().iter().map(|b| format!("{b:02x}")).collect()
     180           83 : }
     181              : 
     182              : /// Read the configuration, a missing configuration is created with a new token.
     183              : /// The folder and the file are readable by the user only.
     184           46 : pub fn load_config(home: &Path, env: &dyn Fn(&str) -> Option<String>) -> Result<AgentConfig, String> {
     185           46 :     create_private_dir(home)?;
     186           46 :     let mut stored = read_stored_config(home)?;
     187           46 :     if stored.get("token").and_then(Value::as_str).is_none_or(str::is_empty) {
     188           11 :         stored.insert("token".into(), new_token().into());
     189           11 :         stored.entry("port").or_insert(DEFAULT_PORT.into());
     190           11 :         stored.entry("defaultHarness").or_insert("claude".into());
     191           11 :         save_config(home, &stored)?;
     192           35 :     }
     193           46 :     let mut config = parse_config(&stored)?;
     194           43 :     if let Some(port) = env("DASHQL_AGENT_PORT") {
     195            2 :         config.port = port
     196            2 :             .parse()
     197            2 :             .ok()
     198            2 :             .filter(|p| *p != 0)
     199            2 :             .ok_or_else(|| format!("DASHQL_AGENT_PORT is no valid port: {port}"))?;
     200           41 :     }
     201           42 :     Ok(config)
     202           46 : }
     203              : 
     204           46 : fn parse_config(stored: &Map<String, Value>) -> Result<AgentConfig, String> {
     205           46 :     let port = match stored.get("port") {
     206           31 :         None => DEFAULT_PORT,
     207           15 :         Some(port) => port
     208           15 :             .as_u64()
     209           15 :             .and_then(|p| u16::try_from(p).ok())
     210           15 :             .filter(|p| *p != 0)
     211           15 :             .ok_or_else(|| format!("port is no valid port: {port}"))?,
     212              :     };
     213              :     // New built-in harnesses appear in existing configurations, config.json overrides them by name
     214           46 :     let mut harnesses = match default_harnesses() {
     215           46 :         Value::Object(map) => map,
     216            0 :         _ => unreachable!(),
     217              :     };
     218           46 :     if let Some(stored) = stored.get("harnesses") {
     219            6 :         let stored = stored.as_object().ok_or("harnesses must be an object")?;
     220            6 :         harnesses.extend(stored.clone());
     221           40 :     }
     222          778 :     let strings = |value: &Value, what: &str| -> Result<Vec<String>, String> {
     223          778 :         let list = value.as_array().ok_or(format!("{what} must be a list of strings"))?;
     224         3307 :         list.iter().map(|s| s.as_str().map(str::to_string).ok_or(format!("{what} must be a list of strings"))).collect()
     225          778 :     };
     226           46 :     let harnesses = harnesses
     227           46 :         .iter()
     228          142 :         .map(|(name, harness)| {
     229          781 :             let what = |key: &str| format!("harnesses.{name}.{key}");
     230          142 :             let command = strings(&harness["command"], &what("command"))?;
     231          141 :             if command.is_empty() {
     232            0 :                 return Err(format!("{} is empty", what("command")));
     233          141 :             }
     234          141 :             let optional =
     235          842 :                 |key: &str| (!harness[key].is_null()).then(|| strings(&harness[key], &what(key))).transpose();
     236              :             // A value to pick needs its place in the command
     237          281 :             let choice = |name: &str, values_key: &str, args_key: &str| -> Result<HarnessChoice, String> {
     238          281 :                 let choice = HarnessChoice {
     239          281 :                     values: optional(values_key)?.unwrap_or_default(),
     240          281 :                     args: optional(args_key)?.unwrap_or_default(),
     241              :                 };
     242          281 :                 let placeholder = format!("{{{name}}}");
     243          281 :                 if !choice.values.is_empty() {
     244          138 :                     if !command.contains(&placeholder) {
     245            1 :                         return Err(format!("{} needs {placeholder} in {}", what(values_key), what("command")));
     246          137 :                     }
     247          273 :                     if !choice.args.iter().any(|arg| arg.contains(&placeholder)) {
     248            1 :                         return Err(format!("{} needs {placeholder} in {}", what(values_key), what(args_key)));
     249          136 :                     }
     250          143 :                 }
     251          279 :                 Ok(choice)
     252          281 :             };
     253          139 :             let config = HarnessConfig {
     254          141 :                 resume_args: optional("resumeArgs")?.unwrap_or_default(),
     255          141 :                 model: choice("model", "models", "modelArgs")?,
     256          140 :                 effort: choice("effort", "efforts", "effortArgs")?,
     257          139 :                 command,
     258          139 :                 terminal_command: optional("terminalCommand")?,
     259          139 :                 adapter: HarnessAdapter::parse(harness["adapter"].as_str().unwrap_or("text")),
     260              :             };
     261          139 :             Ok((name.clone(), config))
     262          142 :         })
     263           46 :         .collect::<Result<Vec<_>, String>>()?;
     264           43 :     let origins = match stored.get("origins") {
     265            1 :         Some(origins) => strings(origins, "origins")?,
     266           42 :         None => Vec::new(),
     267              :     };
     268           43 :     Ok(AgentConfig {
     269           43 :         port,
     270           43 :         token: stored.get("token").and_then(Value::as_str).unwrap_or_default().to_string(),
     271           43 :         default_harness: stored.get("defaultHarness").and_then(Value::as_str).unwrap_or("claude").to_string(),
     272           43 :         harnesses,
     273           43 :         origins,
     274           43 :         call_wait: stored.get("callWaitMs").and_then(Value::as_u64).map_or(DEFAULT_CALL_WAIT, Duration::from_millis),
     275           43 :     })
     276           46 : }
     277              : 
     278           46 : fn read_stored_config(home: &Path) -> Result<Map<String, Value>, String> {
     279           46 :     let path = home.join("config.json");
     280           46 :     let text = match std::fs::read_to_string(&path) {
     281           35 :         Ok(text) => text,
     282           11 :         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
     283            0 :         Err(e) => return Err(format!("{}: {e}", path.display())),
     284              :     };
     285           35 :     match serde_json::from_str(&text) {
     286           35 :         Ok(Value::Object(map)) => Ok(map),
     287            0 :         Ok(_) => Err(format!("{}: expected an object", path.display())),
     288            0 :         Err(e) => Err(format!("{}: {e}", path.display())),
     289              :     }
     290           46 : }
     291              : 
     292           11 : fn save_config(home: &Path, config: &Map<String, Value>) -> Result<(), String> {
     293           11 :     write_private_file(&home.join("config.json"), &(to_json_text(&Value::Object(config.clone())) + "\n"))
     294           11 : }
     295              : 
     296              : /// JSON with four spaces of indentation, as the stored files are written
     297           25 : pub fn to_json_text(value: &Value) -> String {
     298           25 :     let text = serde_json::to_string_pretty(value).expect("JSON values serialize");
     299              :     // Strings never hold a raw newline, the leading spaces of a line are its indentation
     300          101 :     let lines = text.lines().map(|line| {
     301          101 :         let content = line.trim_start_matches(' ');
     302          101 :         format!("{}{content}", " ".repeat(2 * (line.len() - content.len())))
     303          101 :     });
     304           25 :     lines.collect::<Vec<_>>().join("\n")
     305           25 : }
     306              : 
     307              : /// Create a folder that only the user can read
     308           92 : pub fn create_private_dir(dir: &Path) -> Result<(), String> {
     309           92 :     let mut builder = std::fs::DirBuilder::new();
     310           92 :     builder.recursive(true);
     311              :     #[cfg(unix)]
     312           92 :     std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
     313           92 :     builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
     314           92 : }
     315              : 
     316              : /// Write a file that only the user can read
     317           25 : pub fn write_private_file(path: &Path, text: &str) -> Result<(), String> {
     318           25 :     let error = |e: std::io::Error| format!("{}: {e}", path.display());
     319           25 :     let mut options = std::fs::OpenOptions::new();
     320           25 :     options.write(true).create(true).truncate(true);
     321              :     #[cfg(unix)]
     322           25 :     std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
     323           25 :     let mut file = options.open(path).map_err(error)?;
     324              :     // The mode applies to new files only
     325              :     #[cfg(unix)]
     326           25 :     file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(error)?;
     327           25 :     std::io::Write::write_all(&mut file, text.as_bytes()).map_err(error)
     328           25 : }
     329              : 
     330              : #[cfg(test)]
     331              : mod tests {
     332              :     use super::*;
     333              : 
     334              :     #[test]
     335            1 :     fn accepts_the_dev_server_only_in_development_builds() {
     336            1 :         let origins = client_origins();
     337            1 :         assert!(origins.contains(&"https://dashql.app") && origins.contains(&"app://bundle"));
     338            1 :         assert!(origins.contains(&"https://stable.dashql.app"));
     339            1 :         assert_eq!(origins.contains(&"http://localhost:9002"), !cfg!(dashql_release));
     340            1 :         assert_eq!(origins.contains(&"http://127.0.0.1:9002"), !cfg!(dashql_release));
     341            1 :     }
     342              : 
     343           72 :     pub fn temp_dir(name: &str) -> PathBuf {
     344           72 :         let base = std::env::var_os("TEST_TMPDIR").map_or_else(std::env::temp_dir, PathBuf::from);
     345           72 :         let dir = base.join(format!("{name}-{}", new_token()));
     346           72 :         std::fs::create_dir_all(&dir).unwrap();
     347           72 :         dir
     348           72 :     }
     349              : 
     350            5 :     fn no_env(_: &str) -> Option<String> {
     351            5 :         None
     352            5 :     }
     353              : 
     354              :     #[test]
     355            1 :     fn creates_a_configuration_with_claude_as_default_harness() {
     356            1 :         let home = temp_dir("dashql-config");
     357            1 :         let config = load_config(&home, &no_env).unwrap();
     358            1 :         assert_eq!(config.default_harness, "claude");
     359            1 :         assert_eq!(config.port, DEFAULT_PORT);
     360            1 :         assert_eq!(config.token.len(), 48);
     361           48 :         assert!(config.token.chars().all(|c| c.is_ascii_hexdigit()));
     362            3 :         let names: Vec<_> = config.harnesses.iter().map(|(n, _)| n.as_str()).collect();
     363            1 :         assert_eq!(names, ["claude", "codex", "opencode"]);
     364            1 :         assert_eq!(load_config(&home, &no_env).unwrap().token, config.token);
     365              :         // The built-in harnesses are not written, so they change with the binary
     366            1 :         let stored: Value = serde_json::from_str(&std::fs::read_to_string(home.join("config.json")).unwrap()).unwrap();
     367            1 :         assert!(stored.get("harnesses").is_none());
     368            1 :     }
     369              : 
     370              :     #[test]
     371            1 :     fn adds_new_built_in_harnesses_to_stored_configurations() {
     372            1 :         let home = temp_dir("dashql-config");
     373            1 :         let stored = json!({
     374            1 :             "port": 1234, "token": "t", "defaultHarness": "mine",
     375            1 :             "harnesses": { "codex": { "command": ["codex2", "{prompt}"], "adapter": "codex" }, "mine": { "command": ["mine", "{prompt}"], "adapter": "text" } },
     376              :         });
     377            1 :         std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
     378            1 :         let config = load_config(&home, &no_env).unwrap();
     379            1 :         assert_eq!(config.default_harness, "mine");
     380            1 :         assert_eq!(config.port, 1234);
     381            4 :         let names: Vec<_> = config.harnesses.iter().map(|(n, _)| n.as_str()).collect();
     382            1 :         assert_eq!(names, ["claude", "codex", "opencode", "mine"]);
     383            1 :         assert_eq!(config.harness("codex").unwrap().command, ["codex2", "{prompt}"]);
     384            1 :         assert_eq!(config.harness("mine").unwrap().adapter, HarnessAdapter::Text);
     385            1 :     }
     386              : 
     387              :     #[test]
     388            1 :     fn does_not_persist_the_port_of_the_environment() {
     389            1 :         let home = temp_dir("dashql-config");
     390            1 :         load_config(&home, &no_env).unwrap();
     391            1 :         let env = |name: &str| (name == "DASHQL_AGENT_PORT").then(|| "4000".to_string());
     392            1 :         assert_eq!(load_config(&home, &env).unwrap().port, 4000);
     393            1 :         let stored: Value = serde_json::from_str(&std::fs::read_to_string(home.join("config.json")).unwrap()).unwrap();
     394            1 :         assert_eq!(stored["port"], DEFAULT_PORT);
     395            1 :         let bad = |name: &str| (name == "DASHQL_AGENT_PORT").then(|| "70000".to_string());
     396            1 :         assert!(load_config(&home, &bad).is_err());
     397            1 :     }
     398              : 
     399              :     #[test]
     400            1 :     fn rejects_malformed_harnesses() {
     401            1 :         let home = temp_dir("dashql-config");
     402            1 :         std::fs::write(home.join("config.json"), r#"{"token": "t", "harnesses": {"x": {"command": "x"}}}"#).unwrap();
     403            1 :         assert_eq!(load_config(&home, &no_env).unwrap_err(), "harnesses.x.command must be a list of strings");
     404            1 :     }
     405              : 
     406              :     #[test]
     407            1 :     fn reads_the_models_and_efforts_of_harnesses() {
     408            1 :         let home = temp_dir("dashql-config");
     409            1 :         let stored = json!({
     410            1 :             "token": "t",
     411            1 :             "harnesses": { "codex": { "command": ["codex", "{model}", "{prompt}"], "models": ["gpt-a"], "modelArgs": ["-m", "{model}"] } },
     412              :         });
     413            1 :         std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
     414            1 :         let config = load_config(&home, &no_env).unwrap();
     415            1 :         let claude = config.harness("claude").unwrap();
     416            1 :         assert_eq!(claude.model.values, ["fable", "opus", "sonnet", "haiku"]);
     417            1 :         assert_eq!(claude.effort.args, ["--effort", "{effort}"]);
     418              :         // An override replaces the built-in harness with its choices
     419            1 :         let codex = config.harness("codex").unwrap();
     420            1 :         assert_eq!(codex.model.values, ["gpt-a"]);
     421            1 :         assert_eq!(codex.model.args, ["-m", "{model}"]);
     422            1 :         assert_eq!(codex.effort, HarnessChoice::default());
     423            1 :         assert!(config.harness("opencode").unwrap().model.values.is_empty());
     424            1 :     }
     425              : 
     426              :     #[test]
     427            1 :     fn rejects_choices_without_their_place_in_the_command() {
     428            1 :         let home = temp_dir("dashql-config");
     429            1 :         let stored = json!({ "token": "t", "harnesses": { "x": { "command": ["x", "{prompt}"], "models": ["m"], "modelArgs": ["-m", "{model}"] } } });
     430            1 :         std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
     431            1 :         assert_eq!(load_config(&home, &no_env).unwrap_err(), "harnesses.x.models needs {model} in harnesses.x.command");
     432            1 :         let stored = json!({ "token": "t", "harnesses": { "x": { "command": ["x", "{effort}", "{prompt}"], "efforts": ["high"], "effortArgs": ["--effort"] } } });
     433            1 :         std::fs::write(home.join("config.json"), stored.to_string()).unwrap();
     434            1 :         let error = load_config(&home, &no_env).unwrap_err();
     435            1 :         assert_eq!(error, "harnesses.x.efforts needs {effort} in harnesses.x.effortArgs");
     436            1 :     }
     437              : 
     438              :     #[test]
     439            1 :     fn resolves_the_home_folder() {
     440            2 :         let env = |name: &str| (name == "HOME").then(|| "/home/u".to_string());
     441            1 :         assert_eq!(agent_home(&env), Path::new("/home/u/.dashql/agent"));
     442            1 :         let env = |name: &str| (name == "DASHQL_AGENT_HOME").then(|| "/tmp/a".to_string());
     443            1 :         assert_eq!(agent_home(&env), Path::new("/tmp/a"));
     444            1 :     }
     445              : }
     446              : 
     447              : #[cfg(test)]
     448              : pub use tests::temp_dir;
        

Generated by: LCOV version 2.0-1