mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Fix fabro-config dependency inversion
Move config/data types from upstream crates (fabro-agent, fabro-mcp, fabro-workflows, fabro-hooks) down into fabro-config so it becomes a leaf crate depending only on fabro-util + external crates. New modules in fabro-config: - mcp.rs: McpServerConfig, McpTransport, McpServerEntry - sandbox.rs: DaytonaConfig, ExeConfig, SshConfig, SandboxConfig, etc. - hook.rs: HookEvent, HookDefinition, HookConfig, HookType, TlsMode - run.rs: RunDefaults, WorkflowRunConfig, LlmConfig, SetupConfig, etc. - project.rs: ProjectConfig, workflow discovery/resolution functions Source crates re-export from fabro-config for backward compatibility. Also removes stale strsim dep and moves toml to dev-deps in fabro-workflows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e079ed2c5
commit
98e8a9b7f6
26 changed files with 1941 additions and 3605 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -1246,6 +1246,7 @@ dependencies = [
|
|||
"clap",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"fabro-config",
|
||||
"fabro-llm",
|
||||
"fabro-mcp",
|
||||
"fabro-util",
|
||||
|
|
@ -1390,13 +1391,11 @@ name = "fabro-config"
|
|||
version = "0.174.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"dirs",
|
||||
"fabro-agent",
|
||||
"fabro-hooks",
|
||||
"fabro-mcp",
|
||||
"fabro-util",
|
||||
"fabro-workflows",
|
||||
"serde",
|
||||
"strsim",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"tracing",
|
||||
|
|
@ -1412,6 +1411,7 @@ dependencies = [
|
|||
"daytona-api-client",
|
||||
"daytona-sdk",
|
||||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"git2",
|
||||
"rand 0.8.5",
|
||||
|
|
@ -1457,6 +1457,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"base64",
|
||||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"openssh",
|
||||
"reqwest 0.12.28",
|
||||
|
|
@ -1513,6 +1514,7 @@ version = "0.174.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-llm",
|
||||
"mockito",
|
||||
"regex",
|
||||
|
|
@ -1586,6 +1588,7 @@ name = "fabro-mcp"
|
|||
version = "0.174.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-config",
|
||||
"futures",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
|
|
@ -1671,6 +1674,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"base64",
|
||||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"openssh",
|
||||
"serde",
|
||||
|
|
@ -1759,6 +1763,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"dotenvy",
|
||||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-daytona",
|
||||
"fabro-devcontainer",
|
||||
"fabro-exe",
|
||||
|
|
@ -1787,7 +1792,6 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"strsim",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ doctest = false
|
|||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
fabro-config = { path = "../fabro-config", features = ["clap"] }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::{
|
|||
AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile,
|
||||
Session, SessionConfig, Turn,
|
||||
};
|
||||
use clap::{Args, Parser, ValueEnum};
|
||||
use clap::{Args, Parser};
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::provider::{ModelId, Provider};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
|
@ -58,20 +58,7 @@ struct Cli {
|
|||
args: AgentArgs,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PermissionLevel {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
Full,
|
||||
}
|
||||
pub use fabro_config::cli::{OutputFormat, PermissionLevel};
|
||||
|
||||
impl AgentArgs {
|
||||
/// Fill `None` fields from cli.toml values, then hardcoded defaults.
|
||||
|
|
|
|||
|
|
@ -10,17 +10,16 @@ doctest = false
|
|||
|
||||
[features]
|
||||
default = []
|
||||
exedev = ["fabro-workflows/exedev"]
|
||||
exedev = []
|
||||
clap = ["dep:clap"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-hooks = { path = "../fabro-hooks" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
fabro-workflows = { path = "../fabro-workflows" }
|
||||
clap = { workspace = true, optional = true }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
strsim = "0.11"
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_agent::cli::{OutputFormat, PermissionLevel};
|
||||
use fabro_workflows::cli::run_config::RunDefaults;
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::run::RunDefaults;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PermissionLevel {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecutionMode {
|
||||
|
|
@ -124,8 +141,7 @@ pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<CliConfig> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_mcp::config::McpTransport;
|
||||
use fabro_workflows::cli::run_config::McpServerEntry;
|
||||
use crate::mcp::{McpServerEntry, McpTransport};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
230
lib/crates/fabro-config/src/hook.rs
Normal file
230
lib/crates/fabro-config/src/hook.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lifecycle events that can trigger user-defined hooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
RunStart,
|
||||
RunComplete,
|
||||
RunFailed,
|
||||
StageStart,
|
||||
StageComplete,
|
||||
StageFailed,
|
||||
StageRetrying,
|
||||
EdgeSelected,
|
||||
ParallelStart,
|
||||
ParallelComplete,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxReady,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxCleanup,
|
||||
CheckpointSaved,
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
PostToolUseFailure,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
/// Whether hooks for this event block execution by default.
|
||||
#[must_use]
|
||||
pub fn is_blocking_by_default(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::RunStart
|
||||
| Self::StageStart
|
||||
| Self::EdgeSelected
|
||||
| Self::PreToolUse
|
||||
| Self::SandboxReady
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::RunStart => "run_start",
|
||||
Self::RunComplete => "run_complete",
|
||||
Self::RunFailed => "run_failed",
|
||||
Self::StageStart => "stage_start",
|
||||
Self::StageComplete => "stage_complete",
|
||||
Self::StageFailed => "stage_failed",
|
||||
Self::StageRetrying => "stage_retrying",
|
||||
Self::EdgeSelected => "edge_selected",
|
||||
Self::ParallelStart => "parallel_start",
|
||||
Self::ParallelComplete => "parallel_complete",
|
||||
Self::SandboxReady => "sandbox_ready",
|
||||
Self::SandboxCleanup => "sandbox_cleanup",
|
||||
Self::CheckpointSaved => "checkpoint_saved",
|
||||
Self::PreToolUse => "pre_tool_use",
|
||||
Self::PostToolUse => "post_tool_use",
|
||||
Self::PostToolUseFailure => "post_tool_use_failure",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS verification mode for HTTP hooks.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
/// Require `https://` and verify certificates (default).
|
||||
#[default]
|
||||
Verify,
|
||||
/// Require `https://` but skip certificate verification.
|
||||
NoVerify,
|
||||
/// Allow `http://`; skip certificate verification for `https://`.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How a hook is executed.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookType {
|
||||
Command {
|
||||
command: String,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
headers: Option<std::collections::HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
allowed_env_vars: Vec<String>,
|
||||
#[serde(default)]
|
||||
tls: TlsMode,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
Agent {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
max_tool_rounds: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single hook definition.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookDefinition {
|
||||
pub name: Option<String>,
|
||||
pub event: HookEvent,
|
||||
/// Inline command shorthand — if set, implies `type = "command"`.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
/// Explicit hook type (command or http). If omitted and `command` is set,
|
||||
/// defaults to `Command`.
|
||||
#[serde(flatten)]
|
||||
pub hook_type: Option<HookType>,
|
||||
/// Regex matched against node_id, handler_type, or event-specific fields.
|
||||
pub matcher: Option<String>,
|
||||
/// Override the event's default blocking behavior.
|
||||
pub blocking: Option<bool>,
|
||||
/// Timeout in milliseconds (default: 60_000).
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Run inside the sandbox (true, default) or on the host (false).
|
||||
pub sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
impl HookDefinition {
|
||||
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
|
||||
/// shorthand, then error.
|
||||
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
|
||||
if let Some(ref ht) = self.hook_type {
|
||||
return Some(Cow::Borrowed(ht));
|
||||
}
|
||||
self.command.as_ref().map(|cmd| {
|
||||
Cow::Owned(HookType::Command {
|
||||
command: cmd.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this hook is blocking for its event.
|
||||
#[must_use]
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
self.blocking
|
||||
.unwrap_or_else(|| self.event.is_blocking_by_default())
|
||||
}
|
||||
|
||||
/// Timeout duration for this hook.
|
||||
///
|
||||
/// Defaults: 30s for prompt hooks, 60s for all others.
|
||||
#[must_use]
|
||||
pub fn timeout(&self) -> std::time::Duration {
|
||||
if let Some(ms) = self.timeout_ms {
|
||||
return std::time::Duration::from_millis(ms);
|
||||
}
|
||||
let default_ms = match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Prompt { .. }) => 30_000,
|
||||
_ => 60_000,
|
||||
};
|
||||
std::time::Duration::from_millis(default_ms)
|
||||
}
|
||||
|
||||
/// Whether this hook runs in the sandbox.
|
||||
#[must_use]
|
||||
pub fn runs_in_sandbox(&self) -> bool {
|
||||
self.sandbox.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The effective name: explicit name or a generated one.
|
||||
#[must_use]
|
||||
pub fn effective_name(&self) -> String {
|
||||
if let Some(ref n) = self.name {
|
||||
return n.clone();
|
||||
}
|
||||
let event_str = self.event.to_string();
|
||||
match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Command { ref command }) => {
|
||||
let short = &command[..command.floor_char_boundary(20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
|
||||
Some(HookType::Prompt { ref prompt, .. })
|
||||
| Some(HookType::Agent { ref prompt, .. }) => {
|
||||
let short = &prompt[..prompt.floor_char_boundary(20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
None => event_str,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level hook configuration: a list of hook definitions.
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookConfig {
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
}
|
||||
|
||||
impl HookConfig {
|
||||
/// Merge with another config. Concatenates lists; on name collisions, `other` wins.
|
||||
#[must_use]
|
||||
pub fn merge(self, other: Self) -> Self {
|
||||
let mut by_name: std::collections::HashMap<String, HookDefinition> =
|
||||
std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
|
||||
for hook in self.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
for hook in other.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
|
||||
let hooks = order
|
||||
.into_iter()
|
||||
.filter_map(|name| by_name.remove(&name))
|
||||
.collect();
|
||||
|
||||
Self { hooks }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
pub mod cli;
|
||||
pub mod hook;
|
||||
pub mod mcp;
|
||||
pub mod project;
|
||||
pub mod run;
|
||||
pub mod sandbox;
|
||||
pub mod server;
|
||||
|
||||
pub use fabro_util::path::expand_tilde;
|
||||
|
|
|
|||
82
lib/crates/fabro-config/src/mcp.rs
Normal file
82
lib/crates/fabro-config/src/mcp.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn default_startup_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
pub fn default_tool_timeout_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_startup_timeout_secs")]
|
||||
pub startup_timeout_secs: u64,
|
||||
#[serde(default = "default_tool_timeout_secs")]
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
#[must_use]
|
||||
pub fn startup_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.startup_timeout_secs)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.tool_timeout_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
/// MCP server that runs inside a sandbox and is accessed via HTTP preview URL.
|
||||
/// During session init, the server is started inside the sandbox and this
|
||||
/// variant is resolved into an `Http` transport using the sandbox's preview URL.
|
||||
Sandbox {
|
||||
command: Vec<String>,
|
||||
port: u16,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// MCP server entry as it appears in TOML config files (without a `name` field).
|
||||
///
|
||||
/// Converted to [`McpServerConfig`] via [`McpServerEntry::into_config`].
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct McpServerEntry {
|
||||
#[serde(flatten)]
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_startup_timeout_secs")]
|
||||
pub startup_timeout_secs: u64,
|
||||
#[serde(default = "default_tool_timeout_secs")]
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl McpServerEntry {
|
||||
pub fn into_config(self, name: String) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
name,
|
||||
transport: self.transport,
|
||||
startup_timeout_secs: self.startup_timeout_secs,
|
||||
tool_timeout_secs: self.tool_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,661 @@
|
|||
pub use fabro_workflows::cli::project_config::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::hook::HookDefinition;
|
||||
use crate::mcp::McpServerEntry;
|
||||
use crate::run::{
|
||||
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, RunDefaults,
|
||||
SetupConfig,
|
||||
};
|
||||
use crate::sandbox::SandboxConfig;
|
||||
|
||||
const CONFIG_FILENAME: &str = "fabro.toml";
|
||||
const SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectConfig {
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub fabro: ProjectFabroConfig,
|
||||
#[serde(default)]
|
||||
pub features: ProjectFeatures,
|
||||
#[serde(alias = "directory")]
|
||||
pub work_dir: Option<String>,
|
||||
pub llm: Option<LlmConfig>,
|
||||
pub setup: Option<SetupConfig>,
|
||||
pub sandbox: Option<SandboxConfig>,
|
||||
pub vars: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub checkpoint: CheckpointConfig,
|
||||
pub pull_request: Option<PullRequestConfig>,
|
||||
pub assets: Option<AssetsConfig>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
/// Convert project config fields into `RunDefaults`.
|
||||
pub fn into_run_defaults(self) -> RunDefaults {
|
||||
RunDefaults {
|
||||
work_dir: self.work_dir,
|
||||
llm: self.llm,
|
||||
setup: self.setup,
|
||||
sandbox: self.sandbox,
|
||||
vars: self.vars,
|
||||
checkpoint: self.checkpoint,
|
||||
pull_request: self.pull_request,
|
||||
assets: self.assets,
|
||||
hooks: self.hooks,
|
||||
mcp_servers: self.mcp_servers,
|
||||
github: self.github,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectFabroConfig {
|
||||
#[serde(default = "default_root")]
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
fn default_root() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
impl Default for ProjectFabroConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root: default_root(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature flags for the project. All features default to `false` (opt-in).
|
||||
#[derive(Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectFeatures {
|
||||
/// Experimental: enable automatic retro generation after workflow runs.
|
||||
#[serde(default)]
|
||||
pub retros: bool,
|
||||
}
|
||||
|
||||
/// Parse a project config from a TOML string.
|
||||
pub fn parse_project_config(content: &str) -> anyhow::Result<ProjectConfig> {
|
||||
let config: ProjectConfig =
|
||||
toml::from_str(content).context("Failed to parse project config")?;
|
||||
if config.version != SUPPORTED_VERSION {
|
||||
bail!(
|
||||
"Unsupported project config version: {}. Only version {SUPPORTED_VERSION} is supported.",
|
||||
config.version,
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load a project config from a file path.
|
||||
pub fn load_project_config(path: &Path) -> anyhow::Result<ProjectConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let config = parse_project_config(&content)?;
|
||||
tracing::debug!(path = %path.display(), root = %config.fabro.root, "Loaded project config");
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Walk ancestor directories from `start` looking for `fabro.toml`.
|
||||
/// Returns the config file path and parsed config, or `None` if not found.
|
||||
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, ProjectConfig)>> {
|
||||
for ancestor in start.ancestors() {
|
||||
let candidate = ancestor.join(CONFIG_FILENAME);
|
||||
if candidate.is_file() {
|
||||
tracing::debug!(path = %candidate.display(), "Discovered project config");
|
||||
let config = load_project_config(&candidate)?;
|
||||
return Ok(Some((candidate, config)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve a workflow argument to a path.
|
||||
///
|
||||
/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is.
|
||||
/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve
|
||||
/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions
|
||||
/// if an `fabro.toml` exists but the workflow wasn't found.
|
||||
pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result<PathBuf> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
resolve_workflow_arg_from(arg, &start)
|
||||
}
|
||||
|
||||
fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
resolve_workflow_arg_impl(arg, start_dir, user_workflows_dir().as_deref())
|
||||
}
|
||||
|
||||
fn resolve_workflow_arg_impl(
|
||||
arg: &Path,
|
||||
start_dir: &Path,
|
||||
user_workflows: Option<&Path>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
if arg.extension().is_some() {
|
||||
tracing::debug!(arg = %arg.display(), "Workflow arg has extension, returning as-is");
|
||||
return Ok(arg.to_path_buf());
|
||||
}
|
||||
|
||||
let name = arg.to_string_lossy();
|
||||
match discover_project_config(start_dir) {
|
||||
Ok(Some((config_path, config))) => {
|
||||
let fabro_root = resolve_fabro_root(&config_path, &config);
|
||||
let project_candidate = fabro_root
|
||||
.join("workflows")
|
||||
.join(&*name)
|
||||
.join("workflow.toml");
|
||||
if project_candidate.is_file() {
|
||||
tracing::debug!(arg = %arg.display(), resolved = %project_candidate.display(), "Resolved workflow name via project config");
|
||||
return Ok(project_candidate);
|
||||
}
|
||||
|
||||
if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) {
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let available = list_available_workflows(Some(&project_wf_dir), user_workflows);
|
||||
if available.is_empty() {
|
||||
bail!(
|
||||
"Unknown workflow '{name}'\n\nNo workflows found in {}",
|
||||
project_wf_dir.display()
|
||||
);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"Unknown workflow '{name}'\n\nAvailable workflows: {}",
|
||||
available.join(", ")
|
||||
);
|
||||
if let Some(suggestion) = find_closest_match(&name, &available) {
|
||||
msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
|
||||
}
|
||||
bail!("{msg}");
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) {
|
||||
return Ok(resolved);
|
||||
}
|
||||
tracing::debug!(arg = %arg.display(), "No project config found, returning literal");
|
||||
Ok(arg.to_path_buf())
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(arg = %arg.display(), error = %err, "Error discovering project config, returning literal");
|
||||
Ok(arg.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a workflow exists in the user-level workflows directory.
|
||||
fn resolve_user_workflow(user_workflows: Option<&Path>, name: &str, arg: &Path) -> Option<PathBuf> {
|
||||
let user_wf = user_workflows?;
|
||||
let candidate = user_wf.join(name).join("workflow.toml");
|
||||
if candidate.is_file() {
|
||||
tracing::debug!(arg = %arg.display(), resolved = %candidate.display(), "Resolved workflow name via user workflows");
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the user-level workflows directory (`~/.fabro/workflows/`).
|
||||
fn user_workflows_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".fabro").join("workflows"))
|
||||
}
|
||||
|
||||
/// Metadata about a discovered workflow.
|
||||
pub struct WorkflowInfo {
|
||||
pub name: String,
|
||||
pub goal: Option<String>,
|
||||
pub source: WorkflowSource,
|
||||
}
|
||||
|
||||
/// Where a workflow was discovered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WorkflowSource {
|
||||
Project,
|
||||
User,
|
||||
}
|
||||
|
||||
/// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`.
|
||||
fn list_workflows_in(workflows_dir: &Path) -> Vec<String> {
|
||||
let Ok(entries) = std::fs::read_dir(workflows_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() && path.join("workflow.toml").is_file() {
|
||||
entry.file_name().to_str().map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the `goal` field from a `workflow.toml` without full config validation.
|
||||
fn read_workflow_goal(workflow_toml: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(workflow_toml).ok()?;
|
||||
let table: toml::Table = content.parse().ok()?;
|
||||
table.get("goal")?.as_str().map(String::from)
|
||||
}
|
||||
|
||||
/// List workflows with metadata by scanning project and user workflow directories.
|
||||
pub fn list_workflows_detailed(
|
||||
project_workflows_dir: Option<&Path>,
|
||||
user_workflows_dir: Option<&Path>,
|
||||
) -> Vec<WorkflowInfo> {
|
||||
let mut infos: Vec<WorkflowInfo> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(dir) = project_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml"));
|
||||
seen.push(name.clone());
|
||||
infos.push(WorkflowInfo {
|
||||
name,
|
||||
goal,
|
||||
source: WorkflowSource::Project,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(dir) = user_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
if !seen.contains(&name) {
|
||||
let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml"));
|
||||
seen.push(name.clone());
|
||||
infos.push(WorkflowInfo {
|
||||
name,
|
||||
goal,
|
||||
source: WorkflowSource::User,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
infos.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
infos
|
||||
}
|
||||
|
||||
/// List workflow names by scanning project and user workflow directories.
|
||||
/// Project workflows appear first; user workflows are deduplicated.
|
||||
pub fn list_available_workflows(
|
||||
project_workflows_dir: Option<&Path>,
|
||||
user_workflows_dir: Option<&Path>,
|
||||
) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(dir) = project_workflows_dir {
|
||||
names.extend(list_workflows_in(dir));
|
||||
}
|
||||
if let Some(dir) = user_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Find the closest match using normalized Levenshtein distance (threshold: 0.5).
|
||||
fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| (c, strsim::normalized_levenshtein(input, c)))
|
||||
.filter(|(_, score)| *score >= 0.5)
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
/// Resolve a workflow argument to a DOT path and optional run config.
|
||||
///
|
||||
/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file,
|
||||
/// loads the run config and resolves the graph path within it.
|
||||
pub fn resolve_workflow(
|
||||
arg: &Path,
|
||||
) -> anyhow::Result<(PathBuf, Option<crate::run::WorkflowRunConfig>)> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
resolve_workflow_from(arg, &start)
|
||||
}
|
||||
|
||||
fn resolve_workflow_from(
|
||||
arg: &Path,
|
||||
start_dir: &Path,
|
||||
) -> anyhow::Result<(PathBuf, Option<crate::run::WorkflowRunConfig>)> {
|
||||
let path = resolve_workflow_arg_from(arg, start_dir)?;
|
||||
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||
let cfg = crate::run::load_run_config(&path)?;
|
||||
let dot = crate::run::resolve_graph_path(&path, &cfg.graph);
|
||||
Ok((dot, Some(cfg)))
|
||||
} else {
|
||||
Ok((path, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether retros are enabled in the project config.
|
||||
/// Returns `false` (the default) if no config is found or on error.
|
||||
/// Retros are an experimental feature gated behind `[features] retros = true`.
|
||||
pub fn is_retro_enabled() -> bool {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
match discover_project_config(&start) {
|
||||
Ok(Some((_path, config))) => config.features.retros,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the fabro root directory from a config file path and its config.
|
||||
/// The returned path is the directory containing `fabro.toml` joined with the `root` value.
|
||||
pub fn resolve_fabro_root(config_path: &Path, config: &ProjectConfig) -> PathBuf {
|
||||
let project_dir = config_path
|
||||
.parent()
|
||||
.expect("config_path should have a parent directory");
|
||||
project_dir.join(&config.fabro.root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::run::MergeStrategy;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert_eq!(
|
||||
config,
|
||||
ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: ".".to_string(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_config() {
|
||||
let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap();
|
||||
assert_eq!(config.fabro.root, "fabro/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retros_default_false() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert!(!config.features.retros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retros_enabled() {
|
||||
let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap();
|
||||
assert!(config.features.retros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_mismatch() {
|
||||
let err = parse_project_config("version = 2\n").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Unsupported"),
|
||||
"Expected 'Unsupported' in error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_request_config() {
|
||||
let config =
|
||||
parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.pull_request,
|
||||
Some(PullRequestConfig {
|
||||
enabled: true,
|
||||
draft: false,
|
||||
auto_merge: false,
|
||||
merge_strategy: MergeStrategy::Squash,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_sandbox() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "my-snapshot"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
|
||||
let snap = sandbox.daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(snap.name, "my-snapshot");
|
||||
assert_eq!(snap.cpu, Some(4));
|
||||
assert_eq!(snap.memory, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_hooks_and_mcp() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo start"
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
command = ["npx", "@playwright/mcp@latest"]
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
assert_eq!(config.mcp_servers.len(), 1);
|
||||
assert!(config.mcp_servers.contains_key("playwright"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_llm_and_work_dir() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
work_dir = "/workspace"
|
||||
[llm]
|
||||
model = "claude-sonnet-4-6"
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.work_dir.as_deref(), Some("/workspace"));
|
||||
assert_eq!(
|
||||
config.llm.unwrap().model.as_deref(),
|
||||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_run_defaults_preserves_fields() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
work_dir = "/ws"
|
||||
[llm]
|
||||
model = "m"
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let defaults = config.into_run_defaults();
|
||||
assert_eq!(defaults.work_dir.as_deref(), Some("/ws"));
|
||||
assert_eq!(defaults.llm.unwrap().model.as_deref(), Some("m"));
|
||||
assert_eq!(
|
||||
defaults.sandbox.unwrap().provider.as_deref(),
|
||||
Some("daytona")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_field_rejected() {
|
||||
let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err();
|
||||
let chain = format!("{err:#}");
|
||||
assert!(chain.contains("unknown field"), "got: {chain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("fabro.toml");
|
||||
fs::write(&path, "version = 1\n").unwrap();
|
||||
let config = load_project_config(&path).unwrap();
|
||||
assert_eq!(config.version, 1);
|
||||
assert_eq!(config.fabro.root, ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_walks_ancestors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let sub = tmp.path().join("sub").join("dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
|
||||
assert_eq!(found_path, tmp.path().join("fabro.toml"));
|
||||
assert_eq!(config.version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = discover_project_config(tmp.path()).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_subdirectory() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: "fabro/".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_dot() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: ".".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_toml_extension_returned_as_is() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_fabro_extension_returned_as_is() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_no_config_returns_literal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("my-workflow");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, wf_dir.join("workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_typo_suggests_similar_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("implement");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"w.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}");
|
||||
assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read" }
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let github = config.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_run_defaults_preserves_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read", issues = "write" }
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let defaults = config.into_run_defaults();
|
||||
let github = defaults.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
assert_eq!(github.permissions["issues"], "write");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
495
lib/crates/fabro-config/src/run.rs
Normal file
495
lib/crates/fabro-config/src/run.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::hook::{HookConfig, HookDefinition};
|
||||
use crate::mcp::McpServerEntry;
|
||||
use crate::sandbox::{DockerfileSource, SandboxConfig};
|
||||
|
||||
const SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct CheckpointConfig {
|
||||
#[serde(default)]
|
||||
pub exclude_globs: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_graph() -> String {
|
||||
"workflow.fabro".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PullRequestConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub auto_merge: bool,
|
||||
#[serde(default)]
|
||||
pub merge_strategy: MergeStrategy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MergeStrategy {
|
||||
#[default]
|
||||
Squash,
|
||||
Merge,
|
||||
Rebase,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct AssetsConfig {
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct GitHubConfig {
|
||||
#[serde(default)]
|
||||
pub permissions: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkflowRunConfig {
|
||||
pub version: u32,
|
||||
pub goal: Option<String>,
|
||||
#[serde(default = "default_graph")]
|
||||
pub graph: String,
|
||||
#[serde(alias = "directory")]
|
||||
pub work_dir: Option<String>,
|
||||
pub llm: Option<LlmConfig>,
|
||||
pub setup: Option<SetupConfig>,
|
||||
pub sandbox: Option<SandboxConfig>,
|
||||
pub vars: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub checkpoint: CheckpointConfig,
|
||||
pub pull_request: Option<PullRequestConfig>,
|
||||
pub assets: Option<AssetsConfig>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct LlmConfig {
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fallbacks: Option<HashMap<String, Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SetupConfig {
|
||||
pub commands: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Defaults for workflow runs, loaded from the server config.
|
||||
///
|
||||
/// Fields mirror `WorkflowRunConfig` but are all optional.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct RunDefaults {
|
||||
#[serde(alias = "directory")]
|
||||
pub work_dir: Option<String>,
|
||||
pub llm: Option<LlmConfig>,
|
||||
pub setup: Option<SetupConfig>,
|
||||
pub sandbox: Option<SandboxConfig>,
|
||||
pub vars: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub checkpoint: CheckpointConfig,
|
||||
pub pull_request: Option<PullRequestConfig>,
|
||||
pub assets: Option<AssetsConfig>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
impl WorkflowRunConfig {
|
||||
/// Apply server-level run defaults to this config.
|
||||
///
|
||||
/// Each field uses the first non-`None` value (task config wins).
|
||||
/// Vars are merged: defaults first, then task config overwrites.
|
||||
pub fn apply_defaults(&mut self, defaults: &RunDefaults) {
|
||||
if self.work_dir.is_none() {
|
||||
self.work_dir = defaults.work_dir.clone();
|
||||
}
|
||||
|
||||
match (&mut self.llm, &defaults.llm) {
|
||||
(Some(task), Some(default)) => {
|
||||
if task.model.is_none() {
|
||||
task.model = default.model.clone();
|
||||
}
|
||||
if task.provider.is_none() {
|
||||
task.provider = default.provider.clone();
|
||||
}
|
||||
if task.fallbacks.is_none() {
|
||||
task.fallbacks = default.fallbacks.clone();
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => self.llm = defaults.llm.clone(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (&mut self.setup, &defaults.setup) {
|
||||
(Some(task), Some(default)) => {
|
||||
if task.timeout_ms.is_none() {
|
||||
task.timeout_ms = default.timeout_ms;
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => self.setup = defaults.setup.clone(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (&mut self.sandbox, &defaults.sandbox) {
|
||||
(Some(task), Some(default)) => {
|
||||
if task.provider.is_none() {
|
||||
task.provider = default.provider.clone();
|
||||
}
|
||||
if task.preserve.is_none() {
|
||||
task.preserve = default.preserve;
|
||||
}
|
||||
if task.devcontainer.is_none() {
|
||||
task.devcontainer = default.devcontainer;
|
||||
}
|
||||
if task.local.is_none() {
|
||||
task.local = default.local.clone();
|
||||
}
|
||||
match (&mut task.daytona, &default.daytona) {
|
||||
(Some(task_d), Some(default_d)) => {
|
||||
if task_d.auto_stop_interval.is_none() {
|
||||
task_d.auto_stop_interval = default_d.auto_stop_interval;
|
||||
}
|
||||
if task_d.snapshot.is_none() {
|
||||
task_d.snapshot = default_d.snapshot.clone();
|
||||
}
|
||||
if let Some(ref default_labels) = default_d.labels {
|
||||
let mut merged = default_labels.clone();
|
||||
if let Some(ref task_labels) = task_d.labels {
|
||||
merged.extend(task_labels.clone());
|
||||
}
|
||||
task_d.labels = Some(merged);
|
||||
}
|
||||
if task_d.network.is_none() {
|
||||
task_d.network = default_d.network.clone();
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => task.daytona = default.daytona.clone(),
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(feature = "exedev")]
|
||||
match (&mut task.exe, &default.exe) {
|
||||
(Some(task_e), Some(default_e)) => {
|
||||
if task_e.image.is_none() {
|
||||
task_e.image = default_e.image.clone();
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => task.exe = default.exe.clone(),
|
||||
_ => {}
|
||||
}
|
||||
if task.ssh.is_none() {
|
||||
task.ssh = default.ssh.clone();
|
||||
}
|
||||
if let Some(ref default_env) = default.env {
|
||||
let mut merged = default_env.clone();
|
||||
if let Some(ref task_env) = task.env {
|
||||
merged.extend(task_env.clone());
|
||||
}
|
||||
task.env = Some(merged);
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => self.sandbox = defaults.sandbox.clone(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(ref default_vars) = defaults.vars {
|
||||
let mut merged = default_vars.clone();
|
||||
if let Some(ref task_vars) = self.vars {
|
||||
merged.extend(task_vars.clone());
|
||||
}
|
||||
self.vars = Some(merged);
|
||||
}
|
||||
|
||||
if !defaults.checkpoint.exclude_globs.is_empty() {
|
||||
let mut merged = defaults.checkpoint.exclude_globs.clone();
|
||||
merged.append(&mut self.checkpoint.exclude_globs.clone());
|
||||
merged.sort();
|
||||
merged.dedup();
|
||||
self.checkpoint.exclude_globs = merged;
|
||||
}
|
||||
|
||||
if self.pull_request.is_none() {
|
||||
self.pull_request = defaults.pull_request.clone();
|
||||
}
|
||||
|
||||
if self.assets.is_none() {
|
||||
self.assets = defaults.assets.clone();
|
||||
}
|
||||
|
||||
// Merge hooks: defaults as base, workflow overrides by name
|
||||
if !defaults.hooks.is_empty() {
|
||||
let base = HookConfig {
|
||||
hooks: defaults.hooks.clone(),
|
||||
};
|
||||
let overlay = HookConfig {
|
||||
hooks: std::mem::take(&mut self.hooks),
|
||||
};
|
||||
self.hooks = base.merge(overlay).hooks;
|
||||
}
|
||||
|
||||
// Merge mcp_servers: defaults as base, workflow overrides by key
|
||||
if !defaults.mcp_servers.is_empty() {
|
||||
let mut merged = defaults.mcp_servers.clone();
|
||||
merged.extend(std::mem::take(&mut self.mcp_servers));
|
||||
self.mcp_servers = merged;
|
||||
}
|
||||
|
||||
if self.github.is_none() {
|
||||
self.github = defaults.github.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RunDefaults {
|
||||
/// Merge an overlay on top of this base. The overlay takes precedence
|
||||
/// for simple fields; compound fields (vars, hooks, mcp_servers) are
|
||||
/// deep-merged with the overlay winning on collision.
|
||||
///
|
||||
/// Uses the same deep-merge semantics as `WorkflowRunConfig::apply_defaults`.
|
||||
pub fn merge_overlay(&mut self, overlay: RunDefaults) {
|
||||
if overlay.work_dir.is_some() {
|
||||
self.work_dir = overlay.work_dir;
|
||||
}
|
||||
|
||||
match (&mut self.llm, overlay.llm) {
|
||||
(Some(base), Some(over)) => {
|
||||
if over.model.is_some() {
|
||||
base.model = over.model;
|
||||
}
|
||||
if over.provider.is_some() {
|
||||
base.provider = over.provider;
|
||||
}
|
||||
if over.fallbacks.is_some() {
|
||||
base.fallbacks = over.fallbacks;
|
||||
}
|
||||
}
|
||||
(None, Some(over)) => self.llm = Some(over),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (&mut self.setup, overlay.setup) {
|
||||
(Some(base), Some(over)) => {
|
||||
if over.timeout_ms.is_some() {
|
||||
base.timeout_ms = over.timeout_ms;
|
||||
}
|
||||
}
|
||||
(None, Some(over)) => self.setup = Some(over),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (&mut self.sandbox, overlay.sandbox) {
|
||||
(Some(base), Some(over)) => {
|
||||
if over.provider.is_some() {
|
||||
base.provider = over.provider;
|
||||
}
|
||||
if over.preserve.is_some() {
|
||||
base.preserve = over.preserve;
|
||||
}
|
||||
if over.devcontainer.is_some() {
|
||||
base.devcontainer = over.devcontainer;
|
||||
}
|
||||
if over.local.is_some() {
|
||||
base.local = over.local;
|
||||
}
|
||||
match (&mut base.daytona, over.daytona) {
|
||||
(Some(base_d), Some(over_d)) => {
|
||||
if over_d.auto_stop_interval.is_some() {
|
||||
base_d.auto_stop_interval = over_d.auto_stop_interval;
|
||||
}
|
||||
if over_d.snapshot.is_some() {
|
||||
base_d.snapshot = over_d.snapshot;
|
||||
}
|
||||
if let Some(over_labels) = over_d.labels {
|
||||
let mut merged = base_d.labels.take().unwrap_or_default();
|
||||
merged.extend(over_labels);
|
||||
base_d.labels = Some(merged);
|
||||
}
|
||||
if over_d.network.is_some() {
|
||||
base_d.network = over_d.network;
|
||||
}
|
||||
}
|
||||
(None, Some(over_d)) => base.daytona = Some(over_d),
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(feature = "exedev")]
|
||||
match (&mut base.exe, over.exe) {
|
||||
(Some(base_e), Some(over_e)) => {
|
||||
if over_e.image.is_some() {
|
||||
base_e.image = over_e.image;
|
||||
}
|
||||
}
|
||||
(None, Some(over_e)) => base.exe = Some(over_e),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(over_env) = over.env {
|
||||
let mut merged = base.env.take().unwrap_or_default();
|
||||
merged.extend(over_env);
|
||||
base.env = Some(merged);
|
||||
}
|
||||
}
|
||||
(None, Some(over)) => self.sandbox = Some(over),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(overlay_vars) = overlay.vars {
|
||||
let mut merged = self.vars.take().unwrap_or_default();
|
||||
merged.extend(overlay_vars);
|
||||
self.vars = Some(merged);
|
||||
}
|
||||
|
||||
if !overlay.checkpoint.exclude_globs.is_empty() {
|
||||
self.checkpoint
|
||||
.exclude_globs
|
||||
.append(&mut overlay.checkpoint.exclude_globs.clone());
|
||||
self.checkpoint.exclude_globs.sort();
|
||||
self.checkpoint.exclude_globs.dedup();
|
||||
}
|
||||
|
||||
if overlay.pull_request.is_some() {
|
||||
self.pull_request = overlay.pull_request;
|
||||
}
|
||||
|
||||
if overlay.assets.is_some() {
|
||||
self.assets = overlay.assets;
|
||||
}
|
||||
|
||||
if !overlay.hooks.is_empty() {
|
||||
let base = HookConfig {
|
||||
hooks: std::mem::take(&mut self.hooks),
|
||||
};
|
||||
let over = HookConfig {
|
||||
hooks: overlay.hooks,
|
||||
};
|
||||
self.hooks = base.merge(over).hooks;
|
||||
}
|
||||
|
||||
if !overlay.mcp_servers.is_empty() {
|
||||
let mut merged = std::mem::take(&mut self.mcp_servers);
|
||||
merged.extend(overlay.mcp_servers);
|
||||
self.mcp_servers = merged;
|
||||
}
|
||||
|
||||
if overlay.github.is_some() {
|
||||
self.github = overlay.github;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate a run config from a TOML file.
|
||||
///
|
||||
/// The `graph` path in the returned config is resolved relative to the
|
||||
/// TOML file's parent directory. Any `dockerfile = { path = "..." }` is
|
||||
/// resolved to inline content.
|
||||
pub fn load_run_config(path: &Path) -> anyhow::Result<WorkflowRunConfig> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let mut config = parse_run_config(&contents)?;
|
||||
|
||||
let config_dir = path.parent().unwrap_or(Path::new("."));
|
||||
resolve_dockerfile(&mut config, config_dir)?;
|
||||
resolve_sandbox_env(&mut config)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values.
|
||||
///
|
||||
/// Only whole-value references are supported (no partial interpolation).
|
||||
/// Missing host env vars produce a hard error.
|
||||
fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> {
|
||||
if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) {
|
||||
resolve_env_refs(env)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `${env.VARNAME}` patterns in a map of env vars.
|
||||
///
|
||||
/// If the entire value is `${env.VARNAME}`, it is replaced with the host
|
||||
/// environment variable. Any other value is left as-is. Missing host
|
||||
/// variables produce an error.
|
||||
pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()> {
|
||||
for (key, value) in env.iter_mut() {
|
||||
if let Some(var_name) = value
|
||||
.strip_prefix("${env.")
|
||||
.and_then(|s| s.strip_suffix('}'))
|
||||
{
|
||||
*value = std::env::var(var_name).with_context(|| {
|
||||
format!("sandbox.env.{key}: host environment variable {var_name:?} is not set")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If the config contains a `dockerfile = { path = "..." }`, read the file
|
||||
/// and replace it with `DockerfileSource::Inline(contents)`.
|
||||
fn resolve_dockerfile(config: &mut WorkflowRunConfig, config_dir: &Path) -> anyhow::Result<()> {
|
||||
let source = config
|
||||
.sandbox
|
||||
.as_mut()
|
||||
.and_then(|s| s.daytona.as_mut())
|
||||
.and_then(|d| d.snapshot.as_mut())
|
||||
.and_then(|snap| snap.dockerfile.as_mut());
|
||||
|
||||
if let Some(DockerfileSource::Path { path: ref rel }) = source {
|
||||
let path = config_dir.join(rel);
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read dockerfile at {}", path.display()))?;
|
||||
debug!(path = %path.display(), "Resolved dockerfile from path");
|
||||
*source.unwrap() = DockerfileSource::Inline(contents);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the graph path relative to the TOML file's parent directory.
|
||||
pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf {
|
||||
let graph_path = Path::new(graph);
|
||||
if graph_path.is_absolute() {
|
||||
graph_path.to_path_buf()
|
||||
} else {
|
||||
toml_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join(graph_path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_run_config(contents: &str) -> anyhow::Result<WorkflowRunConfig> {
|
||||
let config: WorkflowRunConfig =
|
||||
toml::from_str(contents).context("Failed to parse run config TOML")?;
|
||||
|
||||
if config.version != SUPPORTED_VERSION {
|
||||
bail!(
|
||||
"Unsupported run config version {}. Only version {SUPPORTED_VERSION} is supported.",
|
||||
config.version
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
188
lib/crates/fabro-config/src/sandbox.rs
Normal file
188
lib/crates/fabro-config/src/sandbox.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for a Daytona cloud sandbox.
|
||||
///
|
||||
/// Doubles as the TOML deserialization target for `[sandbox.daytona]`.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaConfig {
|
||||
pub auto_stop_interval: Option<i32>,
|
||||
pub labels: Option<HashMap<String, String>>,
|
||||
pub snapshot: Option<DaytonaSnapshotConfig>,
|
||||
pub network: Option<DaytonaNetwork>,
|
||||
}
|
||||
|
||||
/// Network access mode for a Daytona sandbox.
|
||||
///
|
||||
/// TOML syntax:
|
||||
/// ```toml
|
||||
/// network = "block" # no egress
|
||||
/// network = "allow_all" # full access (default)
|
||||
/// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DaytonaNetwork {
|
||||
Block,
|
||||
AllowAll,
|
||||
AllowList(Vec<String>),
|
||||
}
|
||||
|
||||
impl Serialize for DaytonaNetwork {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
DaytonaNetwork::Block => serializer.serialize_str("block"),
|
||||
DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"),
|
||||
DaytonaNetwork::AllowList(cidrs) => {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
map.serialize_entry("allow_list", cidrs)?;
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DaytonaNetwork {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct DaytonaNetworkVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DaytonaNetworkVisitor {
|
||||
type Value = DaytonaNetwork;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
r#""block", "allow_all", or {{ allow_list = [...] }}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
|
||||
match value {
|
||||
"block" => Ok(DaytonaNetwork::Block),
|
||||
"allow_all" => Ok(DaytonaNetwork::AllowAll),
|
||||
other => Err(de::Error::custom(format!(
|
||||
"unknown network mode \"{other}\": expected \"block\" or \"allow_all\""
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
|
||||
let Some(key) = map.next_key::<String>()? else {
|
||||
return Err(de::Error::custom(
|
||||
"empty table: expected { allow_list = [...] }",
|
||||
));
|
||||
};
|
||||
|
||||
if key != "allow_list" {
|
||||
return Err(de::Error::custom(format!(
|
||||
"unknown key \"{key}\": expected \"allow_list\""
|
||||
)));
|
||||
}
|
||||
|
||||
let cidrs: Vec<String> = map.next_value()?;
|
||||
|
||||
if cidrs.is_empty() {
|
||||
return Err(de::Error::custom("allow_list must not be empty"));
|
||||
}
|
||||
|
||||
if let Some(extra) = map.next_key::<String>()? {
|
||||
return Err(de::Error::custom(format!(
|
||||
"unexpected key \"{extra}\": allow_list table must have exactly one key"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(DaytonaNetwork::AllowList(cidrs))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(DaytonaNetworkVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Source for a snapshot Dockerfile.
|
||||
///
|
||||
/// TOML syntax:
|
||||
/// ```toml
|
||||
/// dockerfile = "FROM rust:1.85-slim-bookworm" # inline content
|
||||
/// dockerfile = { path = "./Dockerfile" } # file reference
|
||||
/// ```
|
||||
///
|
||||
/// `Path` variants are resolved to `Inline` during config loading
|
||||
/// (see `run_config::resolve_dockerfile`), so downstream consumers
|
||||
/// should only ever see `Inline`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum DockerfileSource {
|
||||
Inline(String),
|
||||
Path { path: String },
|
||||
}
|
||||
|
||||
/// Snapshot configuration: when present, the sandbox is created from a snapshot
|
||||
/// instead of a bare Docker image.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaSnapshotConfig {
|
||||
pub name: String,
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
|
||||
/// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`).
|
||||
#[cfg(feature = "exedev")]
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct ExeConfig {
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`).
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SshConfig {
|
||||
/// SSH destination (e.g. `user@host` or an SSH alias).
|
||||
pub destination: String,
|
||||
/// Remote working directory.
|
||||
pub working_directory: String,
|
||||
/// Optional path to a custom SSH config file.
|
||||
pub config_file: Option<String>,
|
||||
/// Base URL for port previews (e.g. `"http://beast"`).
|
||||
/// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`.
|
||||
pub preview_url_base: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorktreeMode {
|
||||
Always,
|
||||
#[default]
|
||||
Clean,
|
||||
Dirty,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct LocalSandboxConfig {
|
||||
#[serde(default)]
|
||||
pub worktree_mode: WorktreeMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SandboxConfig {
|
||||
pub provider: Option<String>,
|
||||
pub preserve: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub devcontainer: Option<bool>,
|
||||
pub local: Option<LocalSandboxConfig>,
|
||||
pub daytona: Option<DaytonaConfig>,
|
||||
#[cfg(feature = "exedev")]
|
||||
pub exe: Option<ExeConfig>,
|
||||
pub ssh: Option<SshConfig>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_workflows::cli::run_config::RunDefaults;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::run::RunDefaults;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthProvider {
|
||||
|
|
@ -465,7 +466,7 @@ matcher = "agent_loop"
|
|||
assert_eq!(config.run_defaults.hooks.len(), 2);
|
||||
assert_eq!(
|
||||
config.run_defaults.hooks[0].event,
|
||||
fabro_hooks::HookEvent::RunStart
|
||||
crate::hook::HookEvent::RunStart
|
||||
);
|
||||
assert_eq!(
|
||||
config.run_defaults.hooks[0].command.as_deref(),
|
||||
|
|
@ -473,7 +474,7 @@ matcher = "agent_loop"
|
|||
);
|
||||
assert_eq!(
|
||||
config.run_defaults.hooks[1].event,
|
||||
fabro_hooks::HookEvent::StageComplete
|
||||
crate::hook::HookEvent::StageComplete
|
||||
);
|
||||
assert_eq!(
|
||||
config.run_defaults.hooks[1].matcher.as_deref(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -7,147 +7,15 @@ use fabro_agent::sandbox::{
|
|||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use rand::Rng;
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use fabro_github::GitHubAppCredentials;
|
||||
use rand::Rng;
|
||||
|
||||
const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
|
||||
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
|
||||
|
||||
/// Configuration for a Daytona cloud sandbox.
|
||||
///
|
||||
/// Doubles as the TOML deserialization target for `[sandbox.daytona]`.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaConfig {
|
||||
pub auto_stop_interval: Option<i32>,
|
||||
pub labels: Option<HashMap<String, String>>,
|
||||
pub snapshot: Option<DaytonaSnapshotConfig>,
|
||||
pub network: Option<DaytonaNetwork>,
|
||||
}
|
||||
|
||||
/// Network access mode for a Daytona sandbox.
|
||||
///
|
||||
/// TOML syntax:
|
||||
/// ```toml
|
||||
/// network = "block" # no egress
|
||||
/// network = "allow_all" # full access (default)
|
||||
/// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DaytonaNetwork {
|
||||
Block,
|
||||
AllowAll,
|
||||
AllowList(Vec<String>),
|
||||
}
|
||||
|
||||
impl Serialize for DaytonaNetwork {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
DaytonaNetwork::Block => serializer.serialize_str("block"),
|
||||
DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"),
|
||||
DaytonaNetwork::AllowList(cidrs) => {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
map.serialize_entry("allow_list", cidrs)?;
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DaytonaNetwork {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct DaytonaNetworkVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DaytonaNetworkVisitor {
|
||||
type Value = DaytonaNetwork;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
r#""block", "allow_all", or {{ allow_list = [...] }}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
|
||||
match value {
|
||||
"block" => Ok(DaytonaNetwork::Block),
|
||||
"allow_all" => Ok(DaytonaNetwork::AllowAll),
|
||||
other => Err(de::Error::custom(format!(
|
||||
"unknown network mode \"{other}\": expected \"block\" or \"allow_all\""
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
|
||||
let Some(key) = map.next_key::<String>()? else {
|
||||
return Err(de::Error::custom(
|
||||
"empty table: expected { allow_list = [...] }",
|
||||
));
|
||||
};
|
||||
|
||||
if key != "allow_list" {
|
||||
return Err(de::Error::custom(format!(
|
||||
"unknown key \"{key}\": expected \"allow_list\""
|
||||
)));
|
||||
}
|
||||
|
||||
let cidrs: Vec<String> = map.next_value()?;
|
||||
|
||||
if cidrs.is_empty() {
|
||||
return Err(de::Error::custom("allow_list must not be empty"));
|
||||
}
|
||||
|
||||
if let Some(extra) = map.next_key::<String>()? {
|
||||
return Err(de::Error::custom(format!(
|
||||
"unexpected key \"{extra}\": allow_list table must have exactly one key"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(DaytonaNetwork::AllowList(cidrs))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(DaytonaNetworkVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Source for a snapshot Dockerfile.
|
||||
///
|
||||
/// TOML syntax:
|
||||
/// ```toml
|
||||
/// dockerfile = "FROM rust:1.85-slim-bookworm" # inline content
|
||||
/// dockerfile = { path = "./Dockerfile" } # file reference
|
||||
/// ```
|
||||
///
|
||||
/// `Path` variants are resolved to `Inline` during config loading
|
||||
/// (see `run_config::resolve_dockerfile`), so downstream consumers
|
||||
/// should only ever see `Inline`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum DockerfileSource {
|
||||
Inline(String),
|
||||
Path { path: String },
|
||||
}
|
||||
|
||||
/// Snapshot configuration: when present, the sandbox is created from a snapshot
|
||||
/// instead of a bare Docker image.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaSnapshotConfig {
|
||||
pub name: String,
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
pub use fabro_config::sandbox::{
|
||||
DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource,
|
||||
};
|
||||
|
||||
/// Sandbox that runs all operations inside a Daytona cloud sandbox.
|
||||
pub struct DaytonaSandbox {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config", features = ["exedev"] }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use fabro_agent::sandbox::{
|
|||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub use openssh_runner::OpensshRunner;
|
||||
|
|
@ -82,11 +81,7 @@ pub trait SshRunner: Send + Sync {
|
|||
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
/// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`).
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct ExeConfig {
|
||||
pub image: Option<String>,
|
||||
}
|
||||
pub use fabro_config::sandbox::ExeConfig;
|
||||
|
||||
/// Parameters for cloning a git repo into the sandbox during initialization.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,173 +1,4 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::HookEvent;
|
||||
|
||||
/// TLS verification mode for HTTP hooks.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
/// Require `https://` and verify certificates (default).
|
||||
#[default]
|
||||
Verify,
|
||||
/// Require `https://` but skip certificate verification.
|
||||
NoVerify,
|
||||
/// Allow `http://`; skip certificate verification for `https://`.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How a hook is executed.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookType {
|
||||
Command {
|
||||
command: String,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
headers: Option<std::collections::HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
allowed_env_vars: Vec<String>,
|
||||
#[serde(default)]
|
||||
tls: TlsMode,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
},
|
||||
Agent {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
max_tool_rounds: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single hook definition.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookDefinition {
|
||||
pub name: Option<String>,
|
||||
pub event: HookEvent,
|
||||
/// Inline command shorthand — if set, implies `type = "command"`.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
/// Explicit hook type (command or http). If omitted and `command` is set,
|
||||
/// defaults to `Command`.
|
||||
#[serde(flatten)]
|
||||
pub hook_type: Option<HookType>,
|
||||
/// Regex matched against node_id, handler_type, or event-specific fields.
|
||||
pub matcher: Option<String>,
|
||||
/// Override the event's default blocking behavior.
|
||||
pub blocking: Option<bool>,
|
||||
/// Timeout in milliseconds (default: 60_000).
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Run inside the sandbox (true, default) or on the host (false).
|
||||
pub sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
impl HookDefinition {
|
||||
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
|
||||
/// shorthand, then error.
|
||||
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
|
||||
if let Some(ref ht) = self.hook_type {
|
||||
return Some(Cow::Borrowed(ht));
|
||||
}
|
||||
self.command.as_ref().map(|cmd| {
|
||||
Cow::Owned(HookType::Command {
|
||||
command: cmd.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this hook is blocking for its event.
|
||||
#[must_use]
|
||||
pub fn is_blocking(&self) -> bool {
|
||||
self.blocking
|
||||
.unwrap_or_else(|| self.event.is_blocking_by_default())
|
||||
}
|
||||
|
||||
/// Timeout duration for this hook.
|
||||
///
|
||||
/// Defaults: 30s for prompt hooks, 60s for all others.
|
||||
#[must_use]
|
||||
pub fn timeout(&self) -> std::time::Duration {
|
||||
if let Some(ms) = self.timeout_ms {
|
||||
return std::time::Duration::from_millis(ms);
|
||||
}
|
||||
let default_ms = match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Prompt { .. }) => 30_000,
|
||||
_ => 60_000,
|
||||
};
|
||||
std::time::Duration::from_millis(default_ms)
|
||||
}
|
||||
|
||||
/// Whether this hook runs in the sandbox.
|
||||
#[must_use]
|
||||
pub fn runs_in_sandbox(&self) -> bool {
|
||||
self.sandbox.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The effective name: explicit name or a generated one.
|
||||
#[must_use]
|
||||
pub fn effective_name(&self) -> String {
|
||||
if let Some(ref n) = self.name {
|
||||
return n.clone();
|
||||
}
|
||||
let event_str = self.event.to_string();
|
||||
match self.resolved_hook_type().as_deref() {
|
||||
Some(HookType::Command { ref command }) => {
|
||||
let short = &command[..fabro_agent::floor_char_boundary(command, 20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"),
|
||||
Some(HookType::Prompt { ref prompt, .. })
|
||||
| Some(HookType::Agent { ref prompt, .. }) => {
|
||||
let short = &prompt[..fabro_agent::floor_char_boundary(prompt, 20)];
|
||||
format!("{event_str}:{short}")
|
||||
}
|
||||
None => event_str,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level hook configuration: a list of hook definitions.
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct HookConfig {
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
}
|
||||
|
||||
impl HookConfig {
|
||||
/// Merge with another config. Concatenates lists; on name collisions, `other` wins.
|
||||
#[must_use]
|
||||
pub fn merge(self, other: Self) -> Self {
|
||||
let mut by_name: std::collections::HashMap<String, HookDefinition> =
|
||||
std::collections::HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
|
||||
for hook in self.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
for hook in other.hooks {
|
||||
let name = hook.effective_name();
|
||||
if !by_name.contains_key(&name) {
|
||||
order.push(name.clone());
|
||||
}
|
||||
by_name.insert(name, hook);
|
||||
}
|
||||
|
||||
let hooks = order
|
||||
.into_iter()
|
||||
.filter_map(|name| by_name.remove(&name))
|
||||
.collect();
|
||||
|
||||
Self { hooks }
|
||||
}
|
||||
}
|
||||
pub use fabro_config::hook::{HookConfig, HookDefinition, HookEvent, HookType, TlsMode};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -1,67 +1,7 @@
|
|||
pub use fabro_config::hook::HookEvent;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Lifecycle events that can trigger user-defined hooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
RunStart,
|
||||
RunComplete,
|
||||
RunFailed,
|
||||
StageStart,
|
||||
StageComplete,
|
||||
StageFailed,
|
||||
StageRetrying,
|
||||
EdgeSelected,
|
||||
ParallelStart,
|
||||
ParallelComplete,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxReady,
|
||||
/// Reserved: hooks for this event are not yet invoked by the engine.
|
||||
SandboxCleanup,
|
||||
CheckpointSaved,
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
PostToolUseFailure,
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
/// Whether hooks for this event block execution by default.
|
||||
#[must_use]
|
||||
pub fn is_blocking_by_default(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::RunStart
|
||||
| Self::StageStart
|
||||
| Self::EdgeSelected
|
||||
| Self::PreToolUse
|
||||
| Self::SandboxReady
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::RunStart => "run_start",
|
||||
Self::RunComplete => "run_complete",
|
||||
Self::RunFailed => "run_failed",
|
||||
Self::StageStart => "stage_start",
|
||||
Self::StageComplete => "stage_complete",
|
||||
Self::StageFailed => "stage_failed",
|
||||
Self::StageRetrying => "stage_retrying",
|
||||
Self::EdgeSelected => "edge_selected",
|
||||
Self::ParallelStart => "parallel_start",
|
||||
Self::ParallelComplete => "parallel_complete",
|
||||
Self::SandboxReady => "sandbox_ready",
|
||||
Self::SandboxCleanup => "sandbox_cleanup",
|
||||
Self::CheckpointSaved => "checkpoint_saved",
|
||||
Self::PreToolUse => "pre_tool_use",
|
||||
Self::PostToolUse => "post_tool_use",
|
||||
Self::PostToolUseFailure => "post_tool_use_failure",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rich JSON payload sent to hooks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HookContext {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,65 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_startup_timeout_secs")]
|
||||
pub startup_timeout_secs: u64,
|
||||
#[serde(default = "default_tool_timeout_secs")]
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
||||
pub fn default_startup_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
pub fn default_tool_timeout_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
#[must_use]
|
||||
pub fn startup_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.startup_timeout_secs)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.tool_timeout_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
/// MCP server that runs inside a sandbox and is accessed via HTTP preview URL.
|
||||
/// During session init, the server is started inside the sandbox and this
|
||||
/// variant is resolved into an `Http` transport using the sandbox's preview URL.
|
||||
Sandbox {
|
||||
command: Vec<String>,
|
||||
port: u16,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
pub use fabro_config::mcp::{
|
||||
default_startup_timeout_secs, default_tool_timeout_secs, McpServerConfig, McpServerEntry,
|
||||
McpTransport,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn stdio_config_construction() {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use fabro_agent::sandbox::{
|
|||
format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub use openssh_runner::OpensshRunner;
|
||||
|
|
@ -47,19 +46,7 @@ pub trait SshRunner: Send + Sync {
|
|||
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
/// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`).
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct SshConfig {
|
||||
/// SSH destination (e.g. `user@host` or an SSH alias).
|
||||
pub destination: String,
|
||||
/// Remote working directory.
|
||||
pub working_directory: String,
|
||||
/// Optional path to a custom SSH config file.
|
||||
pub config_file: Option<String>,
|
||||
/// Base URL for port previews (e.g. `"http://beast"`).
|
||||
/// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`.
|
||||
pub preview_url_base: Option<String>,
|
||||
}
|
||||
pub use fabro_config::sandbox::SshConfig;
|
||||
|
||||
/// Parameters for cloning a git repo into the sandbox during initialization.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ doctest = false
|
|||
|
||||
[features]
|
||||
default = []
|
||||
exedev = ["dep:fabro-exe"]
|
||||
exedev = ["dep:fabro-exe", "fabro-config/exedev"]
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-hooks = { path = "../fabro-hooks" }
|
||||
fabro-validate = { path = "../fabro-validate" }
|
||||
|
|
@ -45,14 +46,12 @@ rand.workspace = true
|
|||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
toml.workspace = true
|
||||
dirs = "6"
|
||||
regex.workspace = true
|
||||
scopeguard = "1"
|
||||
hex.workspace = true
|
||||
sha2 = { workspace = true }
|
||||
shlex = "1"
|
||||
strsim = "0.11"
|
||||
git2.workspace = true
|
||||
cli-table.workspace = true
|
||||
console.workspace = true
|
||||
|
|
@ -63,6 +62,7 @@ walkdir.workspace = true
|
|||
reqwest.workspace = true
|
||||
[dev-dependencies]
|
||||
base64.workspace = true
|
||||
toml.workspace = true
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
mockito = "1"
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
|
|
|
|||
|
|
@ -1,956 +1,2 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::run_config::{
|
||||
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, McpServerEntry, PullRequestConfig,
|
||||
RunDefaults, SandboxConfig, SetupConfig,
|
||||
};
|
||||
use fabro_hooks::HookDefinition;
|
||||
|
||||
const CONFIG_FILENAME: &str = "fabro.toml";
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectConfig {
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub fabro: ProjectFabroConfig,
|
||||
#[serde(default)]
|
||||
pub features: ProjectFeatures,
|
||||
#[serde(alias = "directory")]
|
||||
pub work_dir: Option<String>,
|
||||
pub llm: Option<LlmConfig>,
|
||||
pub setup: Option<SetupConfig>,
|
||||
pub sandbox: Option<SandboxConfig>,
|
||||
pub vars: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub checkpoint: CheckpointConfig,
|
||||
pub pull_request: Option<PullRequestConfig>,
|
||||
pub assets: Option<AssetsConfig>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
/// Convert project config fields into `RunDefaults`.
|
||||
pub fn into_run_defaults(self) -> RunDefaults {
|
||||
RunDefaults {
|
||||
work_dir: self.work_dir,
|
||||
llm: self.llm,
|
||||
setup: self.setup,
|
||||
sandbox: self.sandbox,
|
||||
vars: self.vars,
|
||||
checkpoint: self.checkpoint,
|
||||
pull_request: self.pull_request,
|
||||
assets: self.assets,
|
||||
hooks: self.hooks,
|
||||
mcp_servers: self.mcp_servers,
|
||||
github: self.github,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectFabroConfig {
|
||||
#[serde(default = "default_root")]
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
fn default_root() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
impl Default for ProjectFabroConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root: default_root(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature flags for the project. All features default to `false` (opt-in).
|
||||
#[derive(Debug, Default, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProjectFeatures {
|
||||
/// Experimental: enable automatic retro generation after workflow runs.
|
||||
#[serde(default)]
|
||||
pub retros: bool,
|
||||
}
|
||||
|
||||
/// Parse a project config from a TOML string.
|
||||
pub fn parse_project_config(content: &str) -> anyhow::Result<ProjectConfig> {
|
||||
let config: ProjectConfig =
|
||||
toml::from_str(content).context("Failed to parse project config")?;
|
||||
if config.version != 1 {
|
||||
bail!(
|
||||
"Unsupported project config version: {}. Only version 1 is supported.",
|
||||
config.version,
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load a project config from a file path.
|
||||
pub fn load_project_config(path: &Path) -> anyhow::Result<ProjectConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let config = parse_project_config(&content)?;
|
||||
tracing::debug!(path = %path.display(), root = %config.fabro.root, "Loaded project config");
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Walk ancestor directories from `start` looking for `fabro.toml`.
|
||||
/// Returns the config file path and parsed config, or `None` if not found.
|
||||
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, ProjectConfig)>> {
|
||||
for ancestor in start.ancestors() {
|
||||
let candidate = ancestor.join(CONFIG_FILENAME);
|
||||
if candidate.is_file() {
|
||||
tracing::debug!(path = %candidate.display(), "Discovered project config");
|
||||
let config = load_project_config(&candidate)?;
|
||||
return Ok(Some((candidate, config)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve a workflow argument to a path.
|
||||
///
|
||||
/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is.
|
||||
/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve
|
||||
/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions
|
||||
/// if an `fabro.toml` exists but the workflow wasn't found.
|
||||
pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result<PathBuf> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
resolve_workflow_arg_from(arg, &start)
|
||||
}
|
||||
|
||||
fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
resolve_workflow_arg_impl(arg, start_dir, user_workflows_dir().as_deref())
|
||||
}
|
||||
|
||||
fn resolve_workflow_arg_impl(
|
||||
arg: &Path,
|
||||
start_dir: &Path,
|
||||
user_workflows: Option<&Path>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
if arg.extension().is_some() {
|
||||
tracing::debug!(arg = %arg.display(), "Workflow arg has extension, returning as-is");
|
||||
return Ok(arg.to_path_buf());
|
||||
}
|
||||
|
||||
let name = arg.to_string_lossy();
|
||||
match discover_project_config(start_dir) {
|
||||
Ok(Some((config_path, config))) => {
|
||||
let fabro_root = resolve_fabro_root(&config_path, &config);
|
||||
let project_candidate = fabro_root
|
||||
.join("workflows")
|
||||
.join(&*name)
|
||||
.join("workflow.toml");
|
||||
if project_candidate.is_file() {
|
||||
tracing::debug!(arg = %arg.display(), resolved = %project_candidate.display(), "Resolved workflow name via project config");
|
||||
return Ok(project_candidate);
|
||||
}
|
||||
|
||||
if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) {
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
let project_wf_dir = fabro_root.join("workflows");
|
||||
let available = list_available_workflows(Some(&project_wf_dir), user_workflows);
|
||||
if available.is_empty() {
|
||||
bail!(
|
||||
"Unknown workflow '{name}'\n\nNo workflows found in {}",
|
||||
project_wf_dir.display()
|
||||
);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"Unknown workflow '{name}'\n\nAvailable workflows: {}",
|
||||
available.join(", ")
|
||||
);
|
||||
if let Some(suggestion) = find_closest_match(&name, &available) {
|
||||
msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
|
||||
}
|
||||
bail!("{msg}");
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) {
|
||||
return Ok(resolved);
|
||||
}
|
||||
tracing::debug!(arg = %arg.display(), "No project config found, returning literal");
|
||||
Ok(arg.to_path_buf())
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(arg = %arg.display(), error = %err, "Error discovering project config, returning literal");
|
||||
Ok(arg.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a workflow exists in the user-level workflows directory.
|
||||
fn resolve_user_workflow(user_workflows: Option<&Path>, name: &str, arg: &Path) -> Option<PathBuf> {
|
||||
let user_wf = user_workflows?;
|
||||
let candidate = user_wf.join(name).join("workflow.toml");
|
||||
if candidate.is_file() {
|
||||
tracing::debug!(arg = %arg.display(), resolved = %candidate.display(), "Resolved workflow name via user workflows");
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the user-level workflows directory (`~/.fabro/workflows/`).
|
||||
fn user_workflows_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".fabro").join("workflows"))
|
||||
}
|
||||
|
||||
/// Metadata about a discovered workflow.
|
||||
pub struct WorkflowInfo {
|
||||
pub name: String,
|
||||
pub goal: Option<String>,
|
||||
pub source: WorkflowSource,
|
||||
}
|
||||
|
||||
/// Where a workflow was discovered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WorkflowSource {
|
||||
Project,
|
||||
User,
|
||||
}
|
||||
|
||||
/// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`.
|
||||
fn list_workflows_in(workflows_dir: &Path) -> Vec<String> {
|
||||
let Ok(entries) = std::fs::read_dir(workflows_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() && path.join("workflow.toml").is_file() {
|
||||
entry.file_name().to_str().map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the `goal` field from a `workflow.toml` without full config validation.
|
||||
fn read_workflow_goal(workflow_toml: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(workflow_toml).ok()?;
|
||||
let table: toml::Table = content.parse().ok()?;
|
||||
table.get("goal")?.as_str().map(String::from)
|
||||
}
|
||||
|
||||
/// List workflows with metadata by scanning project and user workflow directories.
|
||||
pub fn list_workflows_detailed(
|
||||
project_workflows_dir: Option<&Path>,
|
||||
user_workflows_dir: Option<&Path>,
|
||||
) -> Vec<WorkflowInfo> {
|
||||
let mut infos: Vec<WorkflowInfo> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(dir) = project_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml"));
|
||||
seen.push(name.clone());
|
||||
infos.push(WorkflowInfo {
|
||||
name,
|
||||
goal,
|
||||
source: WorkflowSource::Project,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(dir) = user_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
if !seen.contains(&name) {
|
||||
let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml"));
|
||||
seen.push(name.clone());
|
||||
infos.push(WorkflowInfo {
|
||||
name,
|
||||
goal,
|
||||
source: WorkflowSource::User,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
infos.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
infos
|
||||
}
|
||||
|
||||
/// List workflow names by scanning project and user workflow directories.
|
||||
/// Project workflows appear first; user workflows are deduplicated.
|
||||
pub fn list_available_workflows(
|
||||
project_workflows_dir: Option<&Path>,
|
||||
user_workflows_dir: Option<&Path>,
|
||||
) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(dir) = project_workflows_dir {
|
||||
names.extend(list_workflows_in(dir));
|
||||
}
|
||||
if let Some(dir) = user_workflows_dir {
|
||||
for name in list_workflows_in(dir) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Find the closest match using normalized Levenshtein distance (threshold: 0.5).
|
||||
fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| (c, strsim::normalized_levenshtein(input, c)))
|
||||
.filter(|(_, score)| *score >= 0.5)
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
/// Resolve a workflow argument to a DOT path and optional run config.
|
||||
///
|
||||
/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file,
|
||||
/// loads the run config and resolves the graph path within it.
|
||||
pub fn resolve_workflow(
|
||||
arg: &Path,
|
||||
) -> anyhow::Result<(PathBuf, Option<super::run_config::WorkflowRunConfig>)> {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
resolve_workflow_from(arg, &start)
|
||||
}
|
||||
|
||||
fn resolve_workflow_from(
|
||||
arg: &Path,
|
||||
start_dir: &Path,
|
||||
) -> anyhow::Result<(PathBuf, Option<super::run_config::WorkflowRunConfig>)> {
|
||||
let path = resolve_workflow_arg_from(arg, start_dir)?;
|
||||
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||
let cfg = super::run_config::load_run_config(&path)?;
|
||||
let dot = super::run_config::resolve_graph_path(&path, &cfg.graph);
|
||||
Ok((dot, Some(cfg)))
|
||||
} else {
|
||||
Ok((path, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether retros are enabled in the project config.
|
||||
/// Returns `false` (the default) if no config is found or on error.
|
||||
/// Retros are an experimental feature gated behind `[features] retros = true`.
|
||||
pub fn is_retro_enabled() -> bool {
|
||||
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
match discover_project_config(&start) {
|
||||
Ok(Some((_path, config))) => config.features.retros,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the fabro root directory from a config file path and its config.
|
||||
/// The returned path is the directory containing `fabro.toml` joined with the `root` value.
|
||||
pub fn resolve_fabro_root(config_path: &Path, config: &ProjectConfig) -> PathBuf {
|
||||
let project_dir = config_path
|
||||
.parent()
|
||||
.expect("config_path should have a parent directory");
|
||||
project_dir.join(&config.fabro.root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert_eq!(
|
||||
config,
|
||||
ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: ".".to_string(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_config() {
|
||||
let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap();
|
||||
assert_eq!(config.fabro.root, "fabro/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retros_default_false() {
|
||||
let config = parse_project_config("version = 1\n").unwrap();
|
||||
assert!(!config.features.retros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retros_enabled() {
|
||||
let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap();
|
||||
assert!(config.features.retros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_mismatch() {
|
||||
let err = parse_project_config("version = 2\n").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Unsupported"),
|
||||
"Expected 'Unsupported' in error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_request_config() {
|
||||
let config =
|
||||
parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.pull_request,
|
||||
Some(PullRequestConfig {
|
||||
enabled: true,
|
||||
draft: false,
|
||||
auto_merge: false,
|
||||
merge_strategy: crate::cli::run_config::MergeStrategy::Squash,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_sandbox() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "my-snapshot"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let sandbox = config.sandbox.unwrap();
|
||||
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
|
||||
let snap = sandbox.daytona.unwrap().snapshot.unwrap();
|
||||
assert_eq!(snap.name, "my-snapshot");
|
||||
assert_eq!(snap.cpu, Some(4));
|
||||
assert_eq!(snap.memory, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_hooks_and_mcp() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
[[hooks]]
|
||||
event = "run_start"
|
||||
command = "echo start"
|
||||
[mcp_servers.playwright]
|
||||
type = "stdio"
|
||||
command = ["npx", "@playwright/mcp@latest"]
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.hooks.len(), 1);
|
||||
assert_eq!(config.mcp_servers.len(), 1);
|
||||
assert!(config.mcp_servers.contains_key("playwright"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_llm_and_work_dir() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
work_dir = "/workspace"
|
||||
[llm]
|
||||
model = "claude-sonnet-4-6"
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
assert_eq!(config.work_dir.as_deref(), Some("/workspace"));
|
||||
assert_eq!(
|
||||
config.llm.unwrap().model.as_deref(),
|
||||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_run_defaults_preserves_fields() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
work_dir = "/ws"
|
||||
[llm]
|
||||
model = "m"
|
||||
[sandbox]
|
||||
provider = "daytona"
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let defaults = config.into_run_defaults();
|
||||
assert_eq!(defaults.work_dir.as_deref(), Some("/ws"));
|
||||
assert_eq!(defaults.llm.unwrap().model.as_deref(), Some("m"));
|
||||
assert_eq!(
|
||||
defaults.sandbox.unwrap().provider.as_deref(),
|
||||
Some("daytona")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_field_rejected() {
|
||||
let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err();
|
||||
let chain = format!("{err:#}");
|
||||
assert!(chain.contains("unknown field"), "got: {chain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("fabro.toml");
|
||||
fs::write(&path, "version = 1\n").unwrap();
|
||||
let config = load_project_config(&path).unwrap();
|
||||
assert_eq!(config.version, 1);
|
||||
assert_eq!(config.fabro.root, ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_walks_ancestors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let sub = tmp.path().join("sub").join("dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
|
||||
assert_eq!(found_path, tmp.path().join("fabro.toml"));
|
||||
assert_eq!(config.version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_none_when_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = discover_project_config(tmp.path()).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_subdirectory() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: "fabro/".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/fabro/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fabro_root_with_dot() {
|
||||
let config_path = Path::new("/repo/fabro.toml");
|
||||
let config = ProjectConfig {
|
||||
version: 1,
|
||||
fabro: ProjectFabroConfig {
|
||||
root: ".".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_fabro_root(config_path, &config),
|
||||
Path::new("/repo/.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_toml_extension_returned_as_is() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_fabro_extension_returned_as_is() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_no_config_returns_literal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("my-workflow");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap();
|
||||
assert_eq!(result, wf_dir.join("workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_typo_suggests_similar_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("implement");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"w.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}");
|
||||
assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_unknown_lists_available() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
let wf_dir = tmp.path().join("workflows").join("hello");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"w.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_workflow_arg_from(Path::new("zzzzz"), tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Unknown workflow 'zzzzz'"), "got: {msg}");
|
||||
assert!(msg.contains("Available workflows: hello"), "got: {msg}");
|
||||
assert!(!msg.contains("Did you mean"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_no_workflows_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
|
||||
let err = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("No workflows found"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_custom_root_respected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("fabro.toml"),
|
||||
"version = 1\n[fabro]\nroot = \"fabro/\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let wf_dir = tmp.path().join("fabro").join("workflows").join("factory");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_workflow_arg_from(Path::new("factory"), tmp.path()).unwrap();
|
||||
assert_eq!(result, wf_dir.join("workflow.toml"));
|
||||
}
|
||||
|
||||
/// Helper: create a workflow dir with workflow.toml + workflow.fabro inside `base/workflows/{name}/`
|
||||
fn create_workflow_in(base: &Path, name: &str) {
|
||||
let wf_dir = base.join("workflows").join(name);
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.fabro"),
|
||||
"digraph G { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper: create a temp dir with fabro.toml + workflows/{name}/{workflow.toml, workflow.fabro}
|
||||
fn setup_workflow_project(name: &str) -> (TempDir, PathBuf) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
create_workflow_in(tmp.path(), name);
|
||||
let dot_path = tmp
|
||||
.path()
|
||||
.join("workflows")
|
||||
.join(name)
|
||||
.join("workflow.fabro");
|
||||
(tmp, dot_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_bare_name() {
|
||||
let (tmp, expected_dot) = setup_workflow_project("hello");
|
||||
let (dot_path, cfg) = resolve_workflow_from(Path::new("hello"), tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
dot_path.canonicalize().unwrap(),
|
||||
expected_dot.canonicalize().unwrap()
|
||||
);
|
||||
assert!(cfg.is_some(), "expected Some(RunConfig) for bare name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_toml_path() {
|
||||
let (tmp, expected_dot) = setup_workflow_project("hello");
|
||||
let toml_path = tmp.path().join("workflows/hello/workflow.toml");
|
||||
let (dot_path, cfg) = resolve_workflow_from(&toml_path, tmp.path()).unwrap();
|
||||
assert_eq!(dot_path, expected_dot);
|
||||
assert!(cfg.is_some(), "expected Some(RunConfig) for .toml path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_fabro_path() {
|
||||
let (tmp, expected_dot) = setup_workflow_project("hello");
|
||||
let (dot_path, cfg) = resolve_workflow_from(&expected_dot, tmp.path()).unwrap();
|
||||
assert_eq!(dot_path, expected_dot);
|
||||
assert!(cfg.is_none(), "expected None for .fabro path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_user_workflow_found() {
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
// No fabro.toml in project_dir
|
||||
let user_dir = TempDir::new().unwrap();
|
||||
create_workflow_in(user_dir.path(), "my-wf");
|
||||
|
||||
let result = resolve_workflow_arg_impl(
|
||||
Path::new("my-wf"),
|
||||
project_dir.path(),
|
||||
Some(user_dir.path().join("workflows").as_path()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
user_dir.path().join("workflows/my-wf/workflow.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_project_takes_precedence() {
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
create_workflow_in(project_dir.path(), "shared");
|
||||
|
||||
let user_dir = TempDir::new().unwrap();
|
||||
create_workflow_in(user_dir.path(), "shared");
|
||||
|
||||
let result = resolve_workflow_arg_impl(
|
||||
Path::new("shared"),
|
||||
project_dir.path(),
|
||||
Some(user_dir.path().join("workflows").as_path()),
|
||||
)
|
||||
.unwrap();
|
||||
// Should resolve to project, not user
|
||||
assert_eq!(
|
||||
result,
|
||||
project_dir.path().join("workflows/shared/workflow.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_user_fallback_when_project_missing() {
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
// Project has a different workflow
|
||||
create_workflow_in(project_dir.path(), "other");
|
||||
|
||||
let user_dir = TempDir::new().unwrap();
|
||||
create_workflow_in(user_dir.path(), "my-wf");
|
||||
|
||||
let result = resolve_workflow_arg_impl(
|
||||
Path::new("my-wf"),
|
||||
project_dir.path(),
|
||||
Some(user_dir.path().join("workflows").as_path()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
user_dir.path().join("workflows/my-wf/workflow.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_user_workflow_listed_in_error() {
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap();
|
||||
create_workflow_in(project_dir.path(), "proj-wf");
|
||||
|
||||
let user_dir = TempDir::new().unwrap();
|
||||
create_workflow_in(user_dir.path(), "user-wf");
|
||||
|
||||
let err = resolve_workflow_arg_impl(
|
||||
Path::new("nonexistent"),
|
||||
project_dir.path(),
|
||||
Some(user_dir.path().join("workflows").as_path()),
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("proj-wf"), "expected proj-wf in: {msg}");
|
||||
assert!(msg.contains("user-wf"), "expected user-wf in: {msg}");
|
||||
}
|
||||
|
||||
fn create_workflow_with_goal(base: &Path, name: &str, goal: &str) {
|
||||
let wf_dir = base.join("workflows").join(name);
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
fs::write(
|
||||
wf_dir.join("workflow.toml"),
|
||||
format!("version = 1\ngoal = \"{goal}\"\ngraph = \"workflow.fabro\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_workflows_detailed_project_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
create_workflow_in(tmp.path(), "alpha");
|
||||
create_workflow_with_goal(tmp.path(), "beta", "Run tests");
|
||||
|
||||
let wf_dir = tmp.path().join("workflows");
|
||||
let infos = list_workflows_detailed(Some(&wf_dir), None);
|
||||
|
||||
assert_eq!(infos.len(), 2);
|
||||
assert_eq!(infos[0].name, "alpha");
|
||||
assert_eq!(infos[0].goal, None);
|
||||
assert_eq!(infos[0].source, WorkflowSource::Project);
|
||||
assert_eq!(infos[1].name, "beta");
|
||||
assert_eq!(infos[1].goal.as_deref(), Some("Run tests"));
|
||||
assert_eq!(infos[1].source, WorkflowSource::Project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_workflows_detailed_user_only() {
|
||||
let user = TempDir::new().unwrap();
|
||||
create_workflow_with_goal(user.path(), "my-wf", "Deploy app");
|
||||
|
||||
let user_wf_dir = user.path().join("workflows");
|
||||
let infos = list_workflows_detailed(None, Some(&user_wf_dir));
|
||||
|
||||
assert_eq!(infos.len(), 1);
|
||||
assert_eq!(infos[0].name, "my-wf");
|
||||
assert_eq!(infos[0].goal.as_deref(), Some("Deploy app"));
|
||||
assert_eq!(infos[0].source, WorkflowSource::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_workflows_detailed_deduplicates_user() {
|
||||
let project = TempDir::new().unwrap();
|
||||
create_workflow_with_goal(project.path(), "shared", "Project version");
|
||||
|
||||
let user = TempDir::new().unwrap();
|
||||
create_workflow_with_goal(user.path(), "shared", "User version");
|
||||
create_workflow_in(user.path(), "user-only");
|
||||
|
||||
let project_wf_dir = project.path().join("workflows");
|
||||
let user_wf_dir = user.path().join("workflows");
|
||||
let infos = list_workflows_detailed(Some(&project_wf_dir), Some(&user_wf_dir));
|
||||
|
||||
assert_eq!(infos.len(), 2);
|
||||
let shared = infos.iter().find(|w| w.name == "shared").unwrap();
|
||||
assert_eq!(shared.source, WorkflowSource::Project);
|
||||
assert_eq!(shared.goal.as_deref(), Some("Project version"));
|
||||
let user_only = infos.iter().find(|w| w.name == "user-only").unwrap();
|
||||
assert_eq!(user_only.source, WorkflowSource::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_workflows_detailed_sorted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
create_workflow_in(tmp.path(), "zebra");
|
||||
create_workflow_in(tmp.path(), "alpha");
|
||||
create_workflow_in(tmp.path(), "middle");
|
||||
|
||||
let wf_dir = tmp.path().join("workflows");
|
||||
let infos = list_workflows_detailed(Some(&wf_dir), None);
|
||||
let names: Vec<_> = infos.iter().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_workflows_detailed_empty_dirs() {
|
||||
let infos = list_workflows_detailed(None, None);
|
||||
assert!(infos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_workflow_goal_extracts_goal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("workflow.toml");
|
||||
fs::write(
|
||||
&path,
|
||||
"version = 1\ngoal = \"Hello world\"\ngraph = \"w.fabro\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(read_workflow_goal(&path).as_deref(), Some("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_workflow_goal_missing_field() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("workflow.toml");
|
||||
fs::write(&path, "version = 1\ngraph = \"w.fabro\"\n").unwrap();
|
||||
assert_eq!(read_workflow_goal(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_workflow_goal_missing_file() {
|
||||
assert_eq!(
|
||||
read_workflow_goal(Path::new("/nonexistent/workflow.toml")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_config_with_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read" }
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let github = config.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_run_defaults_preserves_github() {
|
||||
let toml = r#"
|
||||
version = 1
|
||||
|
||||
[github]
|
||||
permissions = { contents = "read", issues = "write" }
|
||||
"#;
|
||||
let config = parse_project_config(toml).unwrap();
|
||||
let defaults = config.into_run_defaults();
|
||||
let github = defaults.github.unwrap();
|
||||
assert_eq!(github.permissions["contents"], "read");
|
||||
assert_eq!(github.permissions["issues"], "write");
|
||||
}
|
||||
}
|
||||
// Re-export all project config types from fabro-config for backward compatibility.
|
||||
pub use fabro_config::project::*;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue