Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- crates/forge_ci/src/jobs/bounty_job.rs +74 -0
- crates/forge_ci/src/jobs/build.rs +9 -0
- crates/forge_ci/src/jobs/draft_release_update_job.rs +21 -0
- crates/forge_ci/src/jobs/label_sync_job.rs +20 -0
- crates/forge_ci/src/jobs/lint.rs +68 -0
- crates/forge_ci/src/jobs/mod.rs +21 -0
- crates/forge_ci/src/jobs/release_build_job.rs +103 -0
- crates/forge_ci/src/jobs/release_draft.rs +33 -0
- crates/forge_ci/src/jobs/release_draft_pr.rs +22 -0
- crates/forge_ci/src/jobs/release_homebrew.rs +16 -0
- crates/forge_ci/src/jobs/release_npm.rs +35 -0
- crates/forge_ci/src/lib.rs +4 -0
- crates/forge_ci/src/release_matrix.rs +100 -0
- crates/forge_ci/src/steps/mod.rs +3 -0
- crates/forge_ci/src/steps/setup_protoc.rs +11 -0
- crates/forge_ci/src/workflows/autofix.rs +57 -0
- crates/forge_ci/src/workflows/bounty.rs +47 -0
- crates/forge_ci/src/workflows/ci.rs +82 -0
- crates/forge_ci/src/workflows/labels.rs +21 -0
- crates/forge_ci/src/workflows/mod.rs +17 -0
- crates/forge_ci/src/workflows/release_drafter.rs +36 -0
- crates/forge_ci/src/workflows/release_publish.rs +32 -0
- crates/forge_ci/src/workflows/stale.rs +48 -0
- crates/forge_ci/tests/ci.rs +36 -0
- crates/forge_config/src/auto_dump.rs +25 -0
- crates/forge_config/src/compact.rs +266 -0
- crates/forge_config/src/config.rs +516 -0
- crates/forge_config/src/decimal.rs +94 -0
- crates/forge_config/src/error.rs +18 -0
- crates/forge_config/src/http.rs +95 -0
- crates/forge_config/src/legacy.rs +81 -0
- crates/forge_config/src/lib.rs +29 -0
- crates/forge_config/src/model.rs +26 -0
- crates/forge_config/src/percentage.rs +136 -0
- crates/forge_config/src/reader.rs +314 -0
- crates/forge_config/src/reasoning.rs +57 -0
- crates/forge_config/src/retry.rs +48 -0
- crates/forge_config/src/writer.rs +40 -0
- crates/forge_config/tests/schema.rs +30 -0
- crates/forge_embed/src/lib.rs +39 -0
- crates/forge_eventsource/src/error.rs +59 -0
- crates/forge_eventsource/src/event_source.rs +290 -0
- crates/forge_eventsource/src/lib.rs +33 -0
- crates/forge_eventsource/src/reqwest_ext.rs +17 -0
- crates/forge_eventsource/src/retry.rs +120 -0
- crates/forge_infra/src/auth/error.rs +37 -0
- crates/forge_infra/src/auth/http/anthropic.rs +148 -0
- crates/forge_infra/src/auth/http/github.rs +35 -0
- crates/forge_infra/src/auth/http/mod.rs +7 -0
- crates/forge_infra/src/auth/http/standard.rs +139 -0
crates/forge_ci/src/jobs/bounty_job.rs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Jobs for the bounty management workflow (v2).
|
| 2 |
+
//!
|
| 3 |
+
//! v2 uses a state-reconciliation model: each job fetches the full current
|
| 4 |
+
//! state of an issue or PR from GitHub, computes the desired label set from
|
| 5 |
+
//! the rules engine, diffs current vs desired, and applies the minimal patch.
|
| 6 |
+
//!
|
| 7 |
+
//! Three entry points:
|
| 8 |
+
//! - `sync-all-issues.ts` — fetches all open issues with any bounty label and
|
| 9 |
+
//! reconciles their label sets in one pass. Runs on a schedule and on label
|
| 10 |
+
//! events.
|
| 11 |
+
//! - `sync-pr.ts --pr N` — propagates labels from linked issues to the PR;
|
| 12 |
+
//! handles the rewarded lifecycle on merge.
|
| 13 |
+
|
| 14 |
+
use gh_workflow::*;
|
| 15 |
+
|
| 16 |
+
const SCRIPTS_DIR: &str = ".github/scripts/bounty/src";
|
| 17 |
+
const TSX: &str = "npx tsx";
|
| 18 |
+
|
| 19 |
+
/// Returns a checkout step — required before script invocation.
|
| 20 |
+
fn checkout_step() -> Step<Use> {
|
| 21 |
+
Step::new("Checkout").uses("actions", "checkout", "v6")
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/// Builds a three-step job: checkout + npm install + a single script
|
| 25 |
+
/// invocation.
|
| 26 |
+
fn sync_job(job_name: &str, script: &str, args: String) -> Job {
|
| 27 |
+
let cmd = format!("{TSX} {SCRIPTS_DIR}/{script} {args}");
|
| 28 |
+
Job::new(job_name)
|
| 29 |
+
.add_step(checkout_step())
|
| 30 |
+
.add_step(Step::new("Install npm packages").run("npm install"))
|
| 31 |
+
.add_step(Step::new("Sync bounty labels").run(cmd))
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/// Creates a job that syncs bounty labels across all open issues that carry
|
| 35 |
+
/// any bounty label.
|
| 36 |
+
///
|
| 37 |
+
/// Fetches every open issue with a "bounty" label prefix, computes the desired
|
| 38 |
+
/// state for each, and applies the minimal patch in a single pass.
|
| 39 |
+
///
|
| 40 |
+
/// Triggered on: issues labeled/unlabeled/assigned/unassigned, and on schedule.
|
| 41 |
+
pub fn sync_all_issues_job() -> Job {
|
| 42 |
+
let cmd = format!(
|
| 43 |
+
"{TSX} {SCRIPTS_DIR}/sync-all-issues.ts \
|
| 44 |
+
--repo ${{{{ github.repository }}}} \
|
| 45 |
+
--token ${{{{ secrets.GITHUB_TOKEN }}}} \
|
| 46 |
+
--execute"
|
| 47 |
+
);
|
| 48 |
+
Job::new("Sync all bounty issues")
|
| 49 |
+
.add_step(checkout_step())
|
| 50 |
+
.add_step(Step::new("Install npm packages").run("npm install"))
|
| 51 |
+
.add_step(Step::new("Sync all bounty labels").run(cmd))
|
| 52 |
+
.permissions(Permissions::default().issues(Level::Write))
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/// Creates a job that propagates bounty labels from linked issues to the PR
|
| 56 |
+
/// and handles the rewarded lifecycle when the PR is merged.
|
| 57 |
+
///
|
| 58 |
+
/// Triggered on: pull_request opened/edited/reopened, pull_request_target
|
| 59 |
+
/// closed.
|
| 60 |
+
pub fn sync_pr_job() -> Job {
|
| 61 |
+
sync_job(
|
| 62 |
+
"Sync PR bounty labels",
|
| 63 |
+
"sync-pr.ts",
|
| 64 |
+
"--pr ${{ github.event.pull_request.number }} \
|
| 65 |
+
--repo ${{ github.repository }} \
|
| 66 |
+
--token ${{ secrets.GITHUB_TOKEN }}"
|
| 67 |
+
.to_string(),
|
| 68 |
+
)
|
| 69 |
+
.permissions(
|
| 70 |
+
Permissions::default()
|
| 71 |
+
.issues(Level::Write)
|
| 72 |
+
.pull_requests(Level::Write),
|
| 73 |
+
)
|
| 74 |
+
}
|
crates/forge_ci/src/jobs/build.rs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
|
| 3 |
+
use crate::jobs::release_build_job::release_build_job;
|
| 4 |
+
|
| 5 |
+
/// Create a build job for drafts
|
| 6 |
+
pub fn create_build_release_job_for_publishing() -> Job {
|
| 7 |
+
release_build_job("${{ github.event.release.tag_name }}", true)
|
| 8 |
+
}
|
| 9 |
+
|
crates/forge_ci/src/jobs/draft_release_update_job.rs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
|
| 3 |
+
/// Create a job to update the release draft
|
| 4 |
+
pub fn draft_release_update_job() -> Job {
|
| 5 |
+
Job::new("update_release_draft")
|
| 6 |
+
.add_step(
|
| 7 |
+
Step::new("Auto Labeler")
|
| 8 |
+
.uses("release-drafter", "release-drafter/autolabeler", "v7")
|
| 9 |
+
.if_condition(Expression::new(
|
| 10 |
+
"github.event_name == 'pull_request_target'",
|
| 11 |
+
))
|
| 12 |
+
.env(("GITHUB_TOKEN", "${{ secrets.GITHUB_TOKEN }}"))
|
| 13 |
+
.add_with(("config-name", "release-drafter.yml")),
|
| 14 |
+
)
|
| 15 |
+
.add_step(
|
| 16 |
+
Step::new("Release Drafter")
|
| 17 |
+
.uses("release-drafter", "release-drafter", "v7")
|
| 18 |
+
.env(("GITHUB_TOKEN", "${{ secrets.GITHUB_TOKEN }}"))
|
| 19 |
+
.add_with(("config-name", "release-drafter.yml")),
|
| 20 |
+
)
|
| 21 |
+
}
|
crates/forge_ci/src/jobs/label_sync_job.rs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
|
| 3 |
+
/// Create a job to sync GitHub labels
|
| 4 |
+
pub fn label_sync_job() -> Job {
|
| 5 |
+
Job::new("label-sync")
|
| 6 |
+
.permissions(
|
| 7 |
+
Permissions::default()
|
| 8 |
+
.issues(Level::Write)
|
| 9 |
+
)
|
| 10 |
+
.add_step(
|
| 11 |
+
Step::new("Checkout Code").uses("actions", "checkout", "v6")
|
| 12 |
+
.name("Checkout")
|
| 13 |
+
)
|
| 14 |
+
.add_step(
|
| 15 |
+
Step::new("Sync Labels").run(
|
| 16 |
+
"npx github-label-sync \\\n --access-token ${{ secrets.GITHUB_TOKEN }} \\\n --labels \".github/labels.json\" \\\n ${{ github.repository }}"
|
| 17 |
+
)
|
| 18 |
+
.name("Sync labels")
|
| 19 |
+
)
|
| 20 |
+
}
|
crates/forge_ci/src/jobs/lint.rs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Shared lint commands for CI workflows
|
| 2 |
+
|
| 3 |
+
/// Build a cargo command from parts
|
| 4 |
+
fn cargo_cmd(parts: &[&str]) -> String {
|
| 5 |
+
parts.join(" ")
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
/// Base parts for fmt commands
|
| 9 |
+
fn fmt_base() -> Vec<&'static str> {
|
| 10 |
+
vec!["cargo", "+nightly", "fmt", "--all"]
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/// Base parts for clippy commands (shared across all clippy invocations).
|
| 14 |
+
fn clippy_base() -> Vec<&'static str> {
|
| 15 |
+
vec![
|
| 16 |
+
"cargo",
|
| 17 |
+
"+nightly",
|
| 18 |
+
"clippy",
|
| 19 |
+
"--all-features",
|
| 20 |
+
"--workspace",
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/// Build a cargo fmt command
|
| 25 |
+
pub fn fmt_cmd(fix: bool) -> String {
|
| 26 |
+
let mut parts = fmt_base();
|
| 27 |
+
if !fix {
|
| 28 |
+
parts.push("--check");
|
| 29 |
+
}
|
| 30 |
+
cargo_cmd(&parts)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/// Build a cargo clippy command that checks all targets for general warnings.
|
| 34 |
+
pub fn clippy_cmd(fix: bool) -> String {
|
| 35 |
+
let mut parts = clippy_base();
|
| 36 |
+
parts.push("--all-targets");
|
| 37 |
+
|
| 38 |
+
if fix {
|
| 39 |
+
parts.extend(["--fix", "--allow-dirty"]);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
parts.extend(["--", "-D", "warnings"]);
|
| 43 |
+
|
| 44 |
+
cargo_cmd(&parts)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/// Build a cargo clippy command for UTF-8 and indexing safety lints.
|
| 48 |
+
///
|
| 49 |
+
/// Excludes test code by omitting `--all-targets`.
|
| 50 |
+
pub fn clippy_string_safety_cmd(fix: bool) -> String {
|
| 51 |
+
let mut parts = clippy_base();
|
| 52 |
+
|
| 53 |
+
if fix {
|
| 54 |
+
parts.extend(["--fix", "--allow-dirty"]);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
parts.extend([
|
| 58 |
+
"--",
|
| 59 |
+
"-D",
|
| 60 |
+
"clippy::string_slice",
|
| 61 |
+
"-D",
|
| 62 |
+
"clippy::indexing_slicing",
|
| 63 |
+
"-D",
|
| 64 |
+
"clippy::disallowed_methods",
|
| 65 |
+
]);
|
| 66 |
+
|
| 67 |
+
cargo_cmd(&parts)
|
| 68 |
+
}
|
crates/forge_ci/src/jobs/mod.rs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Jobs for CI workflows
|
| 2 |
+
|
| 3 |
+
mod bounty_job;
|
| 4 |
+
mod draft_release_update_job;
|
| 5 |
+
mod label_sync_job;
|
| 6 |
+
mod lint;
|
| 7 |
+
mod release_build_job;
|
| 8 |
+
mod release_draft;
|
| 9 |
+
mod release_draft_pr;
|
| 10 |
+
mod release_homebrew;
|
| 11 |
+
mod release_npm;
|
| 12 |
+
|
| 13 |
+
pub use bounty_job::*;
|
| 14 |
+
pub use draft_release_update_job::*;
|
| 15 |
+
pub use label_sync_job::*;
|
| 16 |
+
pub use lint::*;
|
| 17 |
+
pub use release_build_job::*;
|
| 18 |
+
pub use release_draft::*;
|
| 19 |
+
pub use release_draft_pr::*;
|
| 20 |
+
pub use release_homebrew::*;
|
| 21 |
+
pub use release_npm::*;
|
crates/forge_ci/src/jobs/release_build_job.rs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::release_matrix::ReleaseMatrix;
|
| 5 |
+
use crate::steps::setup_protoc;
|
| 6 |
+
|
| 7 |
+
#[derive(Clone, Default, Setters)]
|
| 8 |
+
#[setters(strip_option, into)]
|
| 9 |
+
pub struct ReleaseBuilderJob {
|
| 10 |
+
// Required to burn into the binary
|
| 11 |
+
pub version: String,
|
| 12 |
+
|
| 13 |
+
// When provide the generated release will be uploaded
|
| 14 |
+
pub release_id: Option<String>,
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
impl ReleaseBuilderJob {
|
| 18 |
+
pub fn new(version: impl AsRef<str>) -> Self {
|
| 19 |
+
Self { version: version.as_ref().to_string(), release_id: None }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
pub fn into_job(self) -> Job {
|
| 23 |
+
self.into()
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
impl From<ReleaseBuilderJob> for Job {
|
| 28 |
+
fn from(value: ReleaseBuilderJob) -> Job {
|
| 29 |
+
let mut job = Job::new("build-release")
|
| 30 |
+
.strategy(Strategy {
|
| 31 |
+
fail_fast: None,
|
| 32 |
+
max_parallel: None,
|
| 33 |
+
matrix: Some(ReleaseMatrix::default().into()),
|
| 34 |
+
})
|
| 35 |
+
.runs_on("${{ matrix.os }}")
|
| 36 |
+
.permissions(
|
| 37 |
+
Permissions::default()
|
| 38 |
+
.contents(Level::Write)
|
| 39 |
+
.pull_requests(Level::Write),
|
| 40 |
+
)
|
| 41 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 42 |
+
// Install protobuf compiler for non-cross builds
|
| 43 |
+
// Cross builds install protoc via Cross.toml pre-build commands
|
| 44 |
+
.add_step(
|
| 45 |
+
setup_protoc().if_condition(Expression::new("${{ matrix.cross == 'false' }}")),
|
| 46 |
+
)
|
| 47 |
+
// Install Rust with cross-compilation target
|
| 48 |
+
.add_step(
|
| 49 |
+
Step::new("Setup Cross Toolchain")
|
| 50 |
+
.uses("taiki-e", "setup-cross-toolchain-action", "v1")
|
| 51 |
+
.with(("target", "${{ matrix.target }}"))
|
| 52 |
+
.if_condition(Expression::new("${{ matrix.cross == 'false' }}")),
|
| 53 |
+
)
|
| 54 |
+
// Explicitly add the target to ensure it's available
|
| 55 |
+
.add_step(
|
| 56 |
+
Step::new("Add Rust target")
|
| 57 |
+
.run("rustup target add ${{ matrix.target }}")
|
| 58 |
+
.if_condition(Expression::new("${{ matrix.cross == 'false' }}")),
|
| 59 |
+
)
|
| 60 |
+
// Build add link flags
|
| 61 |
+
.add_step(
|
| 62 |
+
Step::new("Set Rust Flags")
|
| 63 |
+
.run(r#"echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV"#)
|
| 64 |
+
.if_condition(Expression::new(
|
| 65 |
+
"!(contains(matrix.target, '-unknown-linux-') || contains(matrix.target, '-android'))",
|
| 66 |
+
)),
|
| 67 |
+
)
|
| 68 |
+
// Build release binary
|
| 69 |
+
// Note: protoc is installed via:
|
| 70 |
+
// - arduino/setup-protoc action for non-cross builds
|
| 71 |
+
// - Cross.toml pre-build commands for cross builds (apt-get install protobuf-compiler)
|
| 72 |
+
.add_step(
|
| 73 |
+
Step::new("Build Binary")
|
| 74 |
+
.uses("ClementTsang", "cargo-action", "v0.0.7")
|
| 75 |
+
.add_with(("command", "build --release"))
|
| 76 |
+
.add_with(("args", "--target ${{ matrix.target }}"))
|
| 77 |
+
.add_with(("use-cross", "${{ matrix.cross }}"))
|
| 78 |
+
.add_with(("cross-version", "0.2.5"))
|
| 79 |
+
.add_env(("RUSTFLAGS", "${{ env.RUSTFLAGS }}"))
|
| 80 |
+
.add_env(("POSTHOG_API_SECRET", "${{secrets.POSTHOG_API_SECRET}}"))
|
| 81 |
+
.add_env(("APP_VERSION", value.version.to_string())),
|
| 82 |
+
);
|
| 83 |
+
|
| 84 |
+
if let Some(release_id) = value.release_id {
|
| 85 |
+
job = job
|
| 86 |
+
// Rename binary to target name
|
| 87 |
+
.add_step(
|
| 88 |
+
Step::new("Copy Binary")
|
| 89 |
+
.run("cp ${{ matrix.binary_path }} ${{ matrix.binary_name }}"),
|
| 90 |
+
)
|
| 91 |
+
// Upload to the generated github release id
|
| 92 |
+
.add_step(
|
| 93 |
+
Step::new("Upload to Release")
|
| 94 |
+
.uses("xresloader", "upload-to-github-release", "v1")
|
| 95 |
+
.add_with(("release_id", release_id))
|
| 96 |
+
.add_with(("file", "${{ matrix.binary_name }}"))
|
| 97 |
+
.add_with(("overwrite", "true")),
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
job
|
| 102 |
+
}
|
| 103 |
+
}
|
crates/forge_ci/src/jobs/release_draft.rs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
use indexmap::indexmap;
|
| 3 |
+
|
| 4 |
+
/// Create a draft release job for GitHub Actions
|
| 5 |
+
pub fn create_draft_release_job(build_job_id: &str) -> Job {
|
| 6 |
+
Job::new("draft_release")
|
| 7 |
+
.name("Draft Release")
|
| 8 |
+
.add_needs(build_job_id)
|
| 9 |
+
.cond(Expression::new(
|
| 10 |
+
"github.event_name == 'push' && github.ref == 'refs/heads/main'",
|
| 11 |
+
))
|
| 12 |
+
// This job only runs on push to main, not needed for release events
|
| 13 |
+
.permissions(
|
| 14 |
+
Permissions::default()
|
| 15 |
+
.contents(Level::Write)
|
| 16 |
+
.pull_requests(Level::Write),
|
| 17 |
+
)
|
| 18 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 19 |
+
.add_step(
|
| 20 |
+
Step::new("Draft Release").uses("release-drafter", "release-drafter", "v7")
|
| 21 |
+
.id("create_release")
|
| 22 |
+
.env(("GITHUB_TOKEN", "${{ secrets.GITHUB_TOKEN }}"))
|
| 23 |
+
.with(("config-name", "release-drafter.yml")),
|
| 24 |
+
)
|
| 25 |
+
.add_step(
|
| 26 |
+
Step::new("Export Outputs").run("echo \"crate_release_id=${{ steps.create_release.outputs.id }}\" >> $GITHUB_OUTPUT && echo \"crate_release_name=${{ steps.create_release.outputs.tag_name }}\" >> $GITHUB_OUTPUT")
|
| 27 |
+
.id("set_output"),
|
| 28 |
+
)
|
| 29 |
+
.outputs(indexmap! {
|
| 30 |
+
"crate_release_name".to_string() => "${{ steps.set_output.outputs.crate_release_name }}".to_string(),
|
| 31 |
+
"crate_release_id".to_string() => "${{ steps.set_output.outputs.crate_release_id }}".to_string()
|
| 32 |
+
})
|
| 33 |
+
}
|
crates/forge_ci/src/jobs/release_draft_pr.rs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
use indexmap::indexmap;
|
| 3 |
+
|
| 4 |
+
/// Create a draft release job for GitHub Actions that runs on PRs
|
| 5 |
+
pub fn create_draft_release_pr_job() -> Job {
|
| 6 |
+
Job::new("draft_release_pr")
|
| 7 |
+
.name("Draft Release for PR")
|
| 8 |
+
.cond(Expression::new(
|
| 9 |
+
"github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci: build all targets')",
|
| 10 |
+
))
|
| 11 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 12 |
+
.add_step(
|
| 13 |
+
Step::new("Set Release Version").run(
|
| 14 |
+
r#"echo "crate_release_name=pr-build-${{ github.event.number }}" >> $GITHUB_OUTPUT && echo "crate_release_id=pr-build-${{ github.event.number }}" >> $GITHUB_OUTPUT"#,
|
| 15 |
+
)
|
| 16 |
+
.id("set_output"),
|
| 17 |
+
)
|
| 18 |
+
.outputs(indexmap! {
|
| 19 |
+
"crate_release_name".to_string() => "${{ steps.set_output.outputs.crate_release_name }}".to_string(),
|
| 20 |
+
"crate_release_id".to_string() => "${{ steps.set_output.outputs.crate_release_id }}".to_string()
|
| 21 |
+
})
|
| 22 |
+
}
|
crates/forge_ci/src/jobs/release_homebrew.rs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
|
| 3 |
+
/// Create a homebrew release job
|
| 4 |
+
pub fn release_homebrew_job() -> Job {
|
| 5 |
+
Job::new("homebrew_release")
|
| 6 |
+
.add_step(
|
| 7 |
+
Step::new("Checkout Code").uses("actions", "checkout", "v6")
|
| 8 |
+
.add_with(("repository", "antinomyhq/homebrew-code-forge"))
|
| 9 |
+
.add_with(("ref", "main"))
|
| 10 |
+
.add_with(("token", "${{ secrets.HOMEBREW_ACCESS }}")),
|
| 11 |
+
)
|
| 12 |
+
// Make script executable and run it with token
|
| 13 |
+
.add_step(
|
| 14 |
+
Step::new("Update Homebrew Formula").run("GITHUB_TOKEN=\"${{ secrets.HOMEBREW_ACCESS }}\" ./update-formula.sh ${{ github.event.release.tag_name }}"),
|
| 15 |
+
)
|
| 16 |
+
}
|
crates/forge_ci/src/jobs/release_npm.rs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
use serde_json::Value;
|
| 3 |
+
|
| 4 |
+
/// Create an NPM release job using matrix strategy for multiple repositories
|
| 5 |
+
pub fn release_npm_job() -> Job {
|
| 6 |
+
let matrix = create_npm_matrix();
|
| 7 |
+
|
| 8 |
+
Job::new("npm_release")
|
| 9 |
+
.strategy(Strategy { fail_fast: None, max_parallel: None, matrix: Some(matrix) })
|
| 10 |
+
.add_step(
|
| 11 |
+
Step::new("Checkout Code")
|
| 12 |
+
.uses("actions", "checkout", "v6")
|
| 13 |
+
.add_with(("repository", "${{ matrix.repository }}"))
|
| 14 |
+
.add_with(("ref", "main"))
|
| 15 |
+
.add_with(("token", "${{ secrets.NPM_ACCESS }}")),
|
| 16 |
+
)
|
| 17 |
+
// Make script executable and run it with token
|
| 18 |
+
.add_step(
|
| 19 |
+
Step::new("Update NPM Package")
|
| 20 |
+
.run("./update-package.sh ${{ github.event.release.tag_name }}")
|
| 21 |
+
.add_env(("AUTO_PUSH", "true"))
|
| 22 |
+
.add_env(("CI", "true"))
|
| 23 |
+
.add_env(("NPM_TOKEN", "${{ secrets.NPM_TOKEN }}")),
|
| 24 |
+
)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/// Creates a matrix Value for NPM repositories
|
| 28 |
+
fn create_npm_matrix() -> Value {
|
| 29 |
+
serde_json::json!({
|
| 30 |
+
"repository": [
|
| 31 |
+
"antinomyhq/npm-code-forge",
|
| 32 |
+
"antinomyhq/npm-forgecode"
|
| 33 |
+
]
|
| 34 |
+
})
|
| 35 |
+
}
|
crates/forge_ci/src/lib.rs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod jobs;
|
| 2 |
+
mod release_matrix;
|
| 3 |
+
pub mod steps;
|
| 4 |
+
pub mod workflows;
|
crates/forge_ci/src/release_matrix.rs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::Serialize;
|
| 2 |
+
use serde_json::Value;
|
| 3 |
+
|
| 4 |
+
/// Matrix entry for build targets
|
| 5 |
+
#[derive(Serialize, Clone)]
|
| 6 |
+
pub struct MatrixEntry {
|
| 7 |
+
pub os: &'static str,
|
| 8 |
+
pub target: &'static str,
|
| 9 |
+
pub binary_name: &'static str,
|
| 10 |
+
pub binary_path: &'static str,
|
| 11 |
+
pub cross: &'static str,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#[derive(Clone)]
|
| 15 |
+
pub struct ReleaseMatrix(Vec<MatrixEntry>);
|
| 16 |
+
|
| 17 |
+
impl Default for ReleaseMatrix {
|
| 18 |
+
/// Returns a vector of all build matrix entries
|
| 19 |
+
fn default() -> Self {
|
| 20 |
+
ReleaseMatrix(vec![
|
| 21 |
+
MatrixEntry {
|
| 22 |
+
os: "ubuntu-latest",
|
| 23 |
+
target: "x86_64-unknown-linux-musl",
|
| 24 |
+
binary_name: "forge-x86_64-unknown-linux-musl",
|
| 25 |
+
binary_path: "target/x86_64-unknown-linux-musl/release/forge",
|
| 26 |
+
cross: "true",
|
| 27 |
+
},
|
| 28 |
+
MatrixEntry {
|
| 29 |
+
os: "ubuntu-latest",
|
| 30 |
+
target: "aarch64-unknown-linux-musl",
|
| 31 |
+
binary_name: "forge-aarch64-unknown-linux-musl",
|
| 32 |
+
binary_path: "target/aarch64-unknown-linux-musl/release/forge",
|
| 33 |
+
cross: "true",
|
| 34 |
+
},
|
| 35 |
+
MatrixEntry {
|
| 36 |
+
os: "ubuntu-latest",
|
| 37 |
+
target: "x86_64-unknown-linux-gnu",
|
| 38 |
+
binary_name: "forge-x86_64-unknown-linux-gnu",
|
| 39 |
+
binary_path: "target/x86_64-unknown-linux-gnu/release/forge",
|
| 40 |
+
cross: "false",
|
| 41 |
+
},
|
| 42 |
+
MatrixEntry {
|
| 43 |
+
os: "ubuntu-latest",
|
| 44 |
+
target: "aarch64-unknown-linux-gnu",
|
| 45 |
+
binary_name: "forge-aarch64-unknown-linux-gnu",
|
| 46 |
+
binary_path: "target/aarch64-unknown-linux-gnu/release/forge",
|
| 47 |
+
cross: "true",
|
| 48 |
+
},
|
| 49 |
+
MatrixEntry {
|
| 50 |
+
os: "macos-latest",
|
| 51 |
+
target: "x86_64-apple-darwin",
|
| 52 |
+
binary_name: "forge-x86_64-apple-darwin",
|
| 53 |
+
binary_path: "target/x86_64-apple-darwin/release/forge",
|
| 54 |
+
cross: "false",
|
| 55 |
+
},
|
| 56 |
+
MatrixEntry {
|
| 57 |
+
os: "macos-latest",
|
| 58 |
+
target: "aarch64-apple-darwin",
|
| 59 |
+
binary_name: "forge-aarch64-apple-darwin",
|
| 60 |
+
binary_path: "target/aarch64-apple-darwin/release/forge",
|
| 61 |
+
cross: "false",
|
| 62 |
+
},
|
| 63 |
+
MatrixEntry {
|
| 64 |
+
os: "windows-latest",
|
| 65 |
+
target: "x86_64-pc-windows-msvc",
|
| 66 |
+
binary_name: "forge-x86_64-pc-windows-msvc.exe",
|
| 67 |
+
binary_path: "target/x86_64-pc-windows-msvc/release/forge.exe",
|
| 68 |
+
cross: "false",
|
| 69 |
+
},
|
| 70 |
+
MatrixEntry {
|
| 71 |
+
os: "windows-latest",
|
| 72 |
+
target: "aarch64-pc-windows-msvc",
|
| 73 |
+
binary_name: "forge-aarch64-pc-windows-msvc.exe",
|
| 74 |
+
binary_path: "target/aarch64-pc-windows-msvc/release/forge.exe",
|
| 75 |
+
cross: "false",
|
| 76 |
+
},
|
| 77 |
+
MatrixEntry {
|
| 78 |
+
os: "ubuntu-latest",
|
| 79 |
+
target: "aarch64-linux-android",
|
| 80 |
+
binary_name: "forge-aarch64-linux-android",
|
| 81 |
+
binary_path: "target/aarch64-linux-android/release/forge",
|
| 82 |
+
cross: "true",
|
| 83 |
+
},
|
| 84 |
+
])
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
impl ReleaseMatrix {
|
| 89 |
+
pub fn entries(&self) -> Vec<MatrixEntry> {
|
| 90 |
+
self.0.clone()
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
impl From<ReleaseMatrix> for Value {
|
| 95 |
+
fn from(value: ReleaseMatrix) -> Self {
|
| 96 |
+
serde_json::json!({
|
| 97 |
+
"include": value.entries()
|
| 98 |
+
})
|
| 99 |
+
}
|
| 100 |
+
}
|
crates/forge_ci/src/steps/mod.rs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod setup_protoc;
|
| 2 |
+
|
| 3 |
+
pub use setup_protoc::setup_protoc;
|
crates/forge_ci/src/steps/setup_protoc.rs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::*;
|
| 2 |
+
|
| 3 |
+
/// Creates a step to setup the Protobuf compiler.
|
| 4 |
+
///
|
| 5 |
+
/// This step is reusable across all CI workflows that need protobuf
|
| 6 |
+
/// compilation.
|
| 7 |
+
pub fn setup_protoc() -> Step<Use> {
|
| 8 |
+
Step::new("Setup Protobuf Compiler")
|
| 9 |
+
.uses("arduino", "setup-protoc", "v3")
|
| 10 |
+
.with(("repo-token", "${{ secrets.GITHUB_TOKEN }}"))
|
| 11 |
+
}
|
crates/forge_ci/src/workflows/autofix.rs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::toolchain::Component;
|
| 3 |
+
use gh_workflow::*;
|
| 4 |
+
|
| 5 |
+
use crate::jobs;
|
| 6 |
+
use crate::steps::setup_protoc;
|
| 7 |
+
|
| 8 |
+
/// Generate the autofix workflow
|
| 9 |
+
pub fn generate_autofix_workflow() {
|
| 10 |
+
let lint_fix_job = Job::new("Lint Fix")
|
| 11 |
+
.permissions(Permissions::default().contents(Level::Read))
|
| 12 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 13 |
+
.add_step(Step::new("Install SQLite").run("sudo apt-get install -y libsqlite3-dev"))
|
| 14 |
+
.add_step(setup_protoc())
|
| 15 |
+
.add_step(
|
| 16 |
+
Step::toolchain()
|
| 17 |
+
.add_nightly()
|
| 18 |
+
.add_component(Component::Clippy)
|
| 19 |
+
.add_component(Component::Rustfmt),
|
| 20 |
+
)
|
| 21 |
+
.add_step(Step::new("Cargo Fmt").run(jobs::fmt_cmd(true)))
|
| 22 |
+
.add_step(Step::new("Cargo Clippy").run(jobs::clippy_cmd(true)))
|
| 23 |
+
.add_step(
|
| 24 |
+
Step::new("Cargo Clippy String Safety").run(jobs::clippy_string_safety_cmd(false)),
|
| 25 |
+
)
|
| 26 |
+
.add_step(Step::new("Autofix").uses(
|
| 27 |
+
"autofix-ci",
|
| 28 |
+
"action",
|
| 29 |
+
"7a166d7532b277f34e16238930461bf77f9d7ed8",
|
| 30 |
+
));
|
| 31 |
+
|
| 32 |
+
let events = Event::default()
|
| 33 |
+
.push(Push::default().add_branch("main"))
|
| 34 |
+
.pull_request(
|
| 35 |
+
PullRequest::default()
|
| 36 |
+
.add_type(PullRequestType::Opened)
|
| 37 |
+
.add_type(PullRequestType::Synchronize)
|
| 38 |
+
.add_type(PullRequestType::Reopened)
|
| 39 |
+
.add_branch("main"),
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
let workflow = Workflow::default()
|
| 43 |
+
.name("autofix.ci")
|
| 44 |
+
.add_env(RustFlags::deny("warnings"))
|
| 45 |
+
.on(events)
|
| 46 |
+
.concurrency(
|
| 47 |
+
Concurrency::default()
|
| 48 |
+
.group("autofix-${{github.ref}}")
|
| 49 |
+
.cancel_in_progress(false),
|
| 50 |
+
)
|
| 51 |
+
.add_job("lint", lint_fix_job);
|
| 52 |
+
|
| 53 |
+
Generate::new(workflow)
|
| 54 |
+
.name("autofix.yml")
|
| 55 |
+
.generate()
|
| 56 |
+
.unwrap();
|
| 57 |
+
}
|
crates/forge_ci/src/workflows/bounty.rs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::jobs::{sync_all_issues_job, sync_pr_job};
|
| 5 |
+
|
| 6 |
+
/// Generate the bounty management workflow (v2).
|
| 7 |
+
///
|
| 8 |
+
/// Two jobs cover the full bounty lifecycle:
|
| 9 |
+
/// - `sync-all-issues`: fetches all open issues with any bounty label and
|
| 10 |
+
/// reconciles their label sets in one pass. Triggered on label/assignment
|
| 11 |
+
/// events and daily on a schedule.
|
| 12 |
+
/// - `sync-pr`: propagates bounty value labels from linked issues to the PR on
|
| 13 |
+
/// open/edit, and applies the rewarded lifecycle on merge.
|
| 14 |
+
pub fn generate_bounty_workflow() {
|
| 15 |
+
let events = Event::default()
|
| 16 |
+
.pull_request(
|
| 17 |
+
PullRequest::default()
|
| 18 |
+
.add_type(PullRequestType::Opened)
|
| 19 |
+
.add_type(PullRequestType::Edited)
|
| 20 |
+
.add_type(PullRequestType::Reopened),
|
| 21 |
+
)
|
| 22 |
+
.pull_request_target(PullRequestTarget::default().add_type(PullRequestType::Closed))
|
| 23 |
+
.issues(
|
| 24 |
+
Issues::default()
|
| 25 |
+
.add_type(IssuesType::Assigned)
|
| 26 |
+
.add_type(IssuesType::Unassigned)
|
| 27 |
+
.add_type(IssuesType::Labeled)
|
| 28 |
+
.add_type(IssuesType::Unlabeled),
|
| 29 |
+
)
|
| 30 |
+
.add_cron_schedule("0 2 * * *");
|
| 31 |
+
|
| 32 |
+
let workflow = Workflow::default()
|
| 33 |
+
.name("Bounty Management")
|
| 34 |
+
.on(events)
|
| 35 |
+
.permissions(
|
| 36 |
+
Permissions::default()
|
| 37 |
+
.issues(Level::Write)
|
| 38 |
+
.pull_requests(Level::Write),
|
| 39 |
+
)
|
| 40 |
+
.add_job("sync-all-issues", sync_all_issues_job())
|
| 41 |
+
.add_job("sync-pr", sync_pr_job());
|
| 42 |
+
|
| 43 |
+
Generate::new(workflow)
|
| 44 |
+
.name("bounty.yml")
|
| 45 |
+
.generate()
|
| 46 |
+
.unwrap();
|
| 47 |
+
}
|
crates/forge_ci/src/workflows/ci.rs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::jobs::{self, ReleaseBuilderJob};
|
| 5 |
+
use crate::steps::setup_protoc;
|
| 6 |
+
|
| 7 |
+
/// Generate the main CI workflow
|
| 8 |
+
pub fn generate_ci_workflow() {
|
| 9 |
+
// Create a basic build job for CI with coverage
|
| 10 |
+
let build_job = Job::new("Build and Test")
|
| 11 |
+
.permissions(Permissions::default().contents(Level::Read))
|
| 12 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 13 |
+
.add_step(setup_protoc())
|
| 14 |
+
.add_step(Step::toolchain().add_stable())
|
| 15 |
+
.add_step(Step::new("Install cargo-llvm-cov").run("cargo install cargo-llvm-cov"))
|
| 16 |
+
.add_step(
|
| 17 |
+
Step::new("Generate coverage")
|
| 18 |
+
.run("cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info"),
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
// Create a performance test job to ensure zsh rprompt stays fast
|
| 22 |
+
let perf_test_job = Job::new("zsh-rprompt-performance")
|
| 23 |
+
.name("Performance: zsh rprompt")
|
| 24 |
+
.permissions(Permissions::default().contents(Level::Read))
|
| 25 |
+
.add_step(Step::new("Checkout Code").uses("actions", "checkout", "v6"))
|
| 26 |
+
.add_step(setup_protoc())
|
| 27 |
+
.add_step(Step::toolchain().add_stable())
|
| 28 |
+
.add_step(
|
| 29 |
+
Step::new("Run performance benchmark")
|
| 30 |
+
.run("./scripts/benchmark.sh --threshold 60 zsh rprompt"),
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
let draft_release_job = jobs::create_draft_release_job("build");
|
| 34 |
+
let draft_release_pr_job = jobs::create_draft_release_pr_job();
|
| 35 |
+
let events = Event::default()
|
| 36 |
+
.push(Push::default().add_branch("main").add_tag("v*"))
|
| 37 |
+
.pull_request(
|
| 38 |
+
PullRequest::default()
|
| 39 |
+
.add_type(PullRequestType::Opened)
|
| 40 |
+
.add_type(PullRequestType::Synchronize)
|
| 41 |
+
.add_type(PullRequestType::Reopened)
|
| 42 |
+
.add_type(PullRequestType::Labeled)
|
| 43 |
+
.add_branch("main"),
|
| 44 |
+
);
|
| 45 |
+
let build_release_pr_job =
|
| 46 |
+
ReleaseBuilderJob::new("${{ needs.draft_release_pr.outputs.crate_release_name }}")
|
| 47 |
+
.into_job()
|
| 48 |
+
.add_needs("draft_release_pr")
|
| 49 |
+
.cond(Expression::new(
|
| 50 |
+
[
|
| 51 |
+
"github.event_name == 'pull_request'",
|
| 52 |
+
"contains(github.event.pull_request.labels.*.name, 'ci: build all targets')",
|
| 53 |
+
]
|
| 54 |
+
.join(" && "),
|
| 55 |
+
));
|
| 56 |
+
let build_release_job =
|
| 57 |
+
ReleaseBuilderJob::new("${{ needs.draft_release.outputs.crate_release_name }}")
|
| 58 |
+
.release_id("${{ needs.draft_release.outputs.crate_release_id }}")
|
| 59 |
+
.into_job()
|
| 60 |
+
.add_needs("draft_release")
|
| 61 |
+
.cond(Expression::new(
|
| 62 |
+
[
|
| 63 |
+
"github.event_name == 'push'",
|
| 64 |
+
"github.ref == 'refs/heads/main'",
|
| 65 |
+
]
|
| 66 |
+
.join(" && "),
|
| 67 |
+
));
|
| 68 |
+
let workflow = Workflow::default()
|
| 69 |
+
.name("ci")
|
| 70 |
+
.add_env(RustFlags::deny("warnings"))
|
| 71 |
+
.on(events)
|
| 72 |
+
.concurrency(Concurrency::default().group("${{ github.workflow }}-${{ github.ref }}"))
|
| 73 |
+
.add_env(("OPENROUTER_API_KEY", "${{secrets.OPENROUTER_API_KEY}}"))
|
| 74 |
+
.add_job("build", build_job)
|
| 75 |
+
.add_job("zsh_rprompt_perf", perf_test_job)
|
| 76 |
+
.add_job("draft_release", draft_release_job)
|
| 77 |
+
.add_job("draft_release_pr", draft_release_pr_job)
|
| 78 |
+
.add_job("build_release", build_release_job)
|
| 79 |
+
.add_job("build_release_pr", build_release_pr_job);
|
| 80 |
+
|
| 81 |
+
Generate::new(workflow).name("ci.yml").generate().unwrap();
|
| 82 |
+
}
|
crates/forge_ci/src/workflows/labels.rs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::jobs::label_sync_job;
|
| 5 |
+
|
| 6 |
+
/// Generate labels workflow
|
| 7 |
+
pub fn generate_labels_workflow() {
|
| 8 |
+
let labels_workflow = Workflow::default()
|
| 9 |
+
.name("Github Label Sync")
|
| 10 |
+
.on(Event {
|
| 11 |
+
push: Some(Push { branches: vec!["main".to_string()], ..Push::default() }),
|
| 12 |
+
..Event::default()
|
| 13 |
+
})
|
| 14 |
+
.permissions(Permissions::default().contents(Level::Write))
|
| 15 |
+
.add_job("label-sync", label_sync_job());
|
| 16 |
+
|
| 17 |
+
Generate::new(labels_workflow)
|
| 18 |
+
.name("labels.yml")
|
| 19 |
+
.generate()
|
| 20 |
+
.unwrap();
|
| 21 |
+
}
|
crates/forge_ci/src/workflows/mod.rs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Workflow definitions for CI/CD
|
| 2 |
+
|
| 3 |
+
mod autofix;
|
| 4 |
+
mod bounty;
|
| 5 |
+
mod ci;
|
| 6 |
+
mod labels;
|
| 7 |
+
mod release_drafter;
|
| 8 |
+
mod release_publish;
|
| 9 |
+
mod stale;
|
| 10 |
+
|
| 11 |
+
pub use autofix::*;
|
| 12 |
+
pub use bounty::*;
|
| 13 |
+
pub use ci::*;
|
| 14 |
+
pub use labels::*;
|
| 15 |
+
pub use release_drafter::*;
|
| 16 |
+
pub use release_publish::*;
|
| 17 |
+
pub use stale::*;
|
crates/forge_ci/src/workflows/release_drafter.rs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::jobs::draft_release_update_job;
|
| 5 |
+
|
| 6 |
+
/// Generate release drafter workflow
|
| 7 |
+
pub fn generate_release_drafter_workflow() {
|
| 8 |
+
let release_drafter = Workflow::default()
|
| 9 |
+
.name("Release Drafter")
|
| 10 |
+
.on(Event {
|
| 11 |
+
push: Some(Push { branches: vec!["main".to_string()], ..Push::default() }),
|
| 12 |
+
pull_request_target: Some(PullRequestTarget {
|
| 13 |
+
types: vec![
|
| 14 |
+
PullRequestType::Opened,
|
| 15 |
+
PullRequestType::Reopened,
|
| 16 |
+
PullRequestType::Synchronize,
|
| 17 |
+
PullRequestType::Labeled,
|
| 18 |
+
PullRequestType::Unlabeled,
|
| 19 |
+
PullRequestType::Closed,
|
| 20 |
+
],
|
| 21 |
+
branches: vec!["main".to_string()],
|
| 22 |
+
}),
|
| 23 |
+
..Event::default()
|
| 24 |
+
})
|
| 25 |
+
.permissions(
|
| 26 |
+
Permissions::default()
|
| 27 |
+
.contents(Level::Write)
|
| 28 |
+
.pull_requests(Level::Write),
|
| 29 |
+
)
|
| 30 |
+
.add_job("update_release_draft", draft_release_update_job());
|
| 31 |
+
|
| 32 |
+
Generate::new(release_drafter)
|
| 33 |
+
.name("release-drafter.yml")
|
| 34 |
+
.generate()
|
| 35 |
+
.unwrap();
|
| 36 |
+
}
|
crates/forge_ci/src/workflows/release_publish.rs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
|
| 4 |
+
use crate::jobs::{ReleaseBuilderJob, release_homebrew_job, release_npm_job};
|
| 5 |
+
|
| 6 |
+
/// Generate npm release workflow
|
| 7 |
+
pub fn release_publish() {
|
| 8 |
+
let release_build_job = ReleaseBuilderJob::new("${{ github.event.release.tag_name }}")
|
| 9 |
+
.release_id("${{ github.event.release.id }}");
|
| 10 |
+
let npm_release_job = release_npm_job().add_needs("build_release");
|
| 11 |
+
let homebrew_release_job = release_homebrew_job().add_needs("build_release");
|
| 12 |
+
|
| 13 |
+
let npm_workflow = Workflow::default()
|
| 14 |
+
.name("Multi Channel Release")
|
| 15 |
+
.on(Event {
|
| 16 |
+
release: Some(Release { types: vec![ReleaseType::Published] }),
|
| 17 |
+
..Event::default()
|
| 18 |
+
})
|
| 19 |
+
.permissions(
|
| 20 |
+
Permissions::default()
|
| 21 |
+
.contents(Level::Write)
|
| 22 |
+
.pull_requests(Level::Write),
|
| 23 |
+
)
|
| 24 |
+
.add_job("build_release", release_build_job.into_job())
|
| 25 |
+
.add_job("npm_release", npm_release_job)
|
| 26 |
+
.add_job("homebrew_release", homebrew_release_job);
|
| 27 |
+
|
| 28 |
+
Generate::new(npm_workflow)
|
| 29 |
+
.name("release.yml")
|
| 30 |
+
.generate()
|
| 31 |
+
.unwrap();
|
| 32 |
+
}
|
crates/forge_ci/src/workflows/stale.rs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use gh_workflow::generate::Generate;
|
| 2 |
+
use gh_workflow::*;
|
| 3 |
+
use indexmap::indexmap;
|
| 4 |
+
use serde_json::json;
|
| 5 |
+
|
| 6 |
+
/// Generate the stale issues and PRs workflow
|
| 7 |
+
pub fn generate_stale_workflow() {
|
| 8 |
+
let workflow = Workflow::default()
|
| 9 |
+
.name("Close Stale Issues and PR")
|
| 10 |
+
.on(Event::default().add_schedule(Schedule::new("0 * * * *")))
|
| 11 |
+
.permissions(
|
| 12 |
+
Permissions::default()
|
| 13 |
+
.issues(Level::Write)
|
| 14 |
+
.pull_requests(Level::Write),
|
| 15 |
+
)
|
| 16 |
+
.env(Env::from(indexmap! {
|
| 17 |
+
"DAYS_BEFORE_ISSUE_STALE".to_string() => json!("30"),
|
| 18 |
+
"DAYS_BEFORE_ISSUE_CLOSE".to_string() => json!("7"),
|
| 19 |
+
"DAYS_BEFORE_PR_STALE".to_string() => json!("5"),
|
| 20 |
+
"DAYS_BEFORE_PR_CLOSE".to_string() => json!("10"),
|
| 21 |
+
}))
|
| 22 |
+
.add_job(
|
| 23 |
+
"stale",
|
| 24 |
+
Job::new("Stale Issues")
|
| 25 |
+
.add_step(
|
| 26 |
+
Step::new("Mark Stale Issues").uses("actions", "stale", "v10")
|
| 27 |
+
.with(Input::from(indexmap! {
|
| 28 |
+
"stale-issue-label".to_string() => json!("state: inactive"),
|
| 29 |
+
"stale-pr-label".to_string() => json!("state: inactive"),
|
| 30 |
+
"stale-issue-message".to_string() => json!(r#"**Action required:** Issue inactive for ${{ env.DAYS_BEFORE_ISSUE_STALE }} days.
|
| 31 |
+
Status update or closure in ${{ env.DAYS_BEFORE_ISSUE_CLOSE }} days."#),
|
| 32 |
+
"close-issue-message".to_string() => json!("Issue closed after ${{ env.DAYS_BEFORE_ISSUE_CLOSE }} days of inactivity."),
|
| 33 |
+
"stale-pr-message".to_string() => json!(r#"**Action required:** PR inactive for ${{ env.DAYS_BEFORE_PR_STALE }} days.
|
| 34 |
+
Status update or closure in ${{ env.DAYS_BEFORE_PR_CLOSE }} days."#),
|
| 35 |
+
"close-pr-message".to_string() => json!("PR closed after ${{ env.DAYS_BEFORE_PR_CLOSE }} days of inactivity."),
|
| 36 |
+
"days-before-issue-stale".to_string() => json!("${{ env.DAYS_BEFORE_ISSUE_STALE }}"),
|
| 37 |
+
"days-before-issue-close".to_string() => json!("${{ env.DAYS_BEFORE_ISSUE_CLOSE }}"),
|
| 38 |
+
"days-before-pr-stale".to_string() => json!("${{ env.DAYS_BEFORE_PR_STALE }}"),
|
| 39 |
+
"days-before-pr-close".to_string() => json!("${{ env.DAYS_BEFORE_PR_CLOSE }}"),
|
| 40 |
+
})),
|
| 41 |
+
),
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
Generate::new(workflow)
|
| 45 |
+
.name("stale.yml")
|
| 46 |
+
.generate()
|
| 47 |
+
.unwrap();
|
| 48 |
+
}
|
crates/forge_ci/tests/ci.rs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_ci::workflows as workflow;
|
| 2 |
+
|
| 3 |
+
#[test]
|
| 4 |
+
fn generate() {
|
| 5 |
+
workflow::generate_ci_workflow();
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
#[test]
|
| 9 |
+
fn test_release_drafter() {
|
| 10 |
+
workflow::generate_release_drafter_workflow();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
#[test]
|
| 14 |
+
fn test_release_workflow() {
|
| 15 |
+
workflow::release_publish();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
#[test]
|
| 19 |
+
fn test_labels_workflow() {
|
| 20 |
+
workflow::generate_labels_workflow();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
#[test]
|
| 24 |
+
fn test_stale_workflow() {
|
| 25 |
+
workflow::generate_stale_workflow();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[test]
|
| 29 |
+
fn test_autofix_workflow() {
|
| 30 |
+
workflow::generate_autofix_workflow();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#[test]
|
| 34 |
+
fn test_bounty_workflow() {
|
| 35 |
+
workflow::generate_bounty_workflow();
|
| 36 |
+
}
|
crates/forge_config/src/auto_dump.rs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use schemars::JsonSchema;
|
| 2 |
+
use serde::{Deserialize, Serialize};
|
| 3 |
+
|
| 4 |
+
/// The output format used when auto-dumping a conversation on task completion.
|
| 5 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)]
|
| 6 |
+
#[serde(rename_all = "snake_case")]
|
| 7 |
+
pub enum AutoDumpFormat {
|
| 8 |
+
/// Dump as a JSON file
|
| 9 |
+
Json,
|
| 10 |
+
/// Dump as an HTML file
|
| 11 |
+
Html,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#[cfg(test)]
|
| 15 |
+
mod tests {
|
| 16 |
+
use pretty_assertions::assert_eq;
|
| 17 |
+
|
| 18 |
+
use super::*;
|
| 19 |
+
|
| 20 |
+
#[test]
|
| 21 |
+
fn test_auto_dump_format_variants() {
|
| 22 |
+
assert_eq!(AutoDumpFormat::Json, AutoDumpFormat::Json);
|
| 23 |
+
assert_eq!(AutoDumpFormat::Html, AutoDumpFormat::Html);
|
| 24 |
+
}
|
| 25 |
+
}
|
crates/forge_config/src/compact.rs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::time::Duration;
|
| 2 |
+
|
| 3 |
+
use derive_setters::Setters;
|
| 4 |
+
use fake::Dummy;
|
| 5 |
+
use schemars::JsonSchema;
|
| 6 |
+
use serde::{Deserialize, Serialize};
|
| 7 |
+
|
| 8 |
+
use crate::Percentage;
|
| 9 |
+
|
| 10 |
+
/// Frequency at which forge checks for updates
|
| 11 |
+
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, fake::Dummy)]
|
| 12 |
+
#[serde(rename_all = "snake_case")]
|
| 13 |
+
pub enum UpdateFrequency {
|
| 14 |
+
Daily,
|
| 15 |
+
Weekly,
|
| 16 |
+
Never,
|
| 17 |
+
#[default]
|
| 18 |
+
Always,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
impl From<UpdateFrequency> for Duration {
|
| 22 |
+
fn from(val: UpdateFrequency) -> Self {
|
| 23 |
+
match val {
|
| 24 |
+
UpdateFrequency::Daily => Duration::from_secs(60 * 60 * 24),
|
| 25 |
+
UpdateFrequency::Weekly => Duration::from_secs(60 * 60 * 24 * 7),
|
| 26 |
+
UpdateFrequency::Never => Duration::MAX,
|
| 27 |
+
UpdateFrequency::Always => Duration::ZERO,
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/// Configuration for automatic forge updates
|
| 33 |
+
#[derive(
|
| 34 |
+
Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Setters, PartialEq, fake::Dummy,
|
| 35 |
+
)]
|
| 36 |
+
#[setters(strip_option, into)]
|
| 37 |
+
pub struct Update {
|
| 38 |
+
/// How frequently forge checks for updates: daily, weekly, always, or never
|
| 39 |
+
pub frequency: Option<UpdateFrequency>,
|
| 40 |
+
/// Whether to automatically install updates without prompting
|
| 41 |
+
pub auto_update: Option<bool>,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/// Configuration for automatic context compaction for all agents
|
| 45 |
+
#[derive(Debug, Clone, Serialize, Deserialize, Setters, JsonSchema, PartialEq)]
|
| 46 |
+
#[setters(strip_option, into)]
|
| 47 |
+
pub struct Compact {
|
| 48 |
+
/// Number of most recent messages to preserve during compaction.
|
| 49 |
+
/// These messages won't be considered for summarization. Works alongside
|
| 50 |
+
/// eviction_window - the more conservative limit (fewer messages to
|
| 51 |
+
/// compact) takes precedence.
|
| 52 |
+
#[serde(default)]
|
| 53 |
+
pub retention_window: usize,
|
| 54 |
+
|
| 55 |
+
/// Maximum percentage of the context that can be summarized during
|
| 56 |
+
/// compaction. Valid values are between 0.0 and 1.0, where 0.0 means no
|
| 57 |
+
/// compaction and 1.0 allows summarizing all messages. Works alongside
|
| 58 |
+
/// retention_window - the more conservative limit (fewer messages to
|
| 59 |
+
/// compact) takes precedence.
|
| 60 |
+
#[serde(default)]
|
| 61 |
+
pub eviction_window: Percentage,
|
| 62 |
+
|
| 63 |
+
/// Maximum number of tokens to keep after compaction
|
| 64 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 65 |
+
pub max_tokens: Option<usize>,
|
| 66 |
+
|
| 67 |
+
/// Maximum number of tokens before triggering compaction. This acts as an
|
| 68 |
+
/// absolute cap and is combined with
|
| 69 |
+
/// `token_threshold_percentage` by taking the lower value.
|
| 70 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 71 |
+
pub token_threshold: Option<usize>,
|
| 72 |
+
|
| 73 |
+
/// Maximum percentage of the model context window used to derive the token
|
| 74 |
+
/// threshold before triggering compaction. This is combined with
|
| 75 |
+
/// `token_threshold` by taking the lower value.
|
| 76 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 77 |
+
pub token_threshold_percentage: Option<Percentage>,
|
| 78 |
+
|
| 79 |
+
/// Maximum number of conversation turns before triggering compaction
|
| 80 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 81 |
+
pub turn_threshold: Option<usize>,
|
| 82 |
+
|
| 83 |
+
/// Maximum number of messages before triggering compaction
|
| 84 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 85 |
+
pub message_threshold: Option<usize>,
|
| 86 |
+
|
| 87 |
+
/// Model ID to use for compaction, useful when compacting with a
|
| 88 |
+
/// cheaper/faster model. If not specified, the root level model will be
|
| 89 |
+
/// used.
|
| 90 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 91 |
+
pub model: Option<String>,
|
| 92 |
+
|
| 93 |
+
/// Whether to trigger compaction when the last message is from a user
|
| 94 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 95 |
+
pub on_turn_end: Option<bool>,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
impl Default for Compact {
|
| 99 |
+
fn default() -> Self {
|
| 100 |
+
Self::new()
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
impl Compact {
|
| 105 |
+
/// Creates a new compaction configuration with all optional fields unset
|
| 106 |
+
pub fn new() -> Self {
|
| 107 |
+
Self {
|
| 108 |
+
max_tokens: None,
|
| 109 |
+
token_threshold: None,
|
| 110 |
+
token_threshold_percentage: None,
|
| 111 |
+
turn_threshold: None,
|
| 112 |
+
message_threshold: None,
|
| 113 |
+
model: None,
|
| 114 |
+
eviction_window: Percentage::new(0.2).unwrap(),
|
| 115 |
+
retention_window: 0,
|
| 116 |
+
on_turn_end: None,
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
impl Dummy<fake::Faker> for Compact {
|
| 122 |
+
fn dummy_with_rng<R: fake::RngExt + ?Sized>(_: &fake::Faker, rng: &mut R) -> Self {
|
| 123 |
+
use fake::Fake;
|
| 124 |
+
Self {
|
| 125 |
+
retention_window: fake::Faker.fake_with_rng(rng),
|
| 126 |
+
eviction_window: Percentage::from((0.0f64..=1.0f64).fake_with_rng::<f64, R>(rng)),
|
| 127 |
+
max_tokens: fake::Faker.fake_with_rng(rng),
|
| 128 |
+
token_threshold: fake::Faker.fake_with_rng(rng),
|
| 129 |
+
token_threshold_percentage: fake::Faker.fake_with_rng(rng),
|
| 130 |
+
turn_threshold: fake::Faker.fake_with_rng(rng),
|
| 131 |
+
message_threshold: fake::Faker.fake_with_rng(rng),
|
| 132 |
+
model: fake::Faker.fake_with_rng(rng),
|
| 133 |
+
on_turn_end: fake::Faker.fake_with_rng(rng),
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
#[cfg(test)]
|
| 139 |
+
mod tests {
|
| 140 |
+
use pretty_assertions::assert_eq;
|
| 141 |
+
|
| 142 |
+
use super::*;
|
| 143 |
+
use crate::ForgeConfig;
|
| 144 |
+
use crate::reader::ConfigReader;
|
| 145 |
+
|
| 146 |
+
#[test]
|
| 147 |
+
fn test_f64_eviction_window_round_trip() {
|
| 148 |
+
let fixture = Compact {
|
| 149 |
+
eviction_window: Percentage::new(0.2).unwrap(),
|
| 150 |
+
..Compact::new()
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 154 |
+
|
| 155 |
+
assert!(
|
| 156 |
+
toml.contains("eviction_window = 0.2\n"),
|
| 157 |
+
"expected `eviction_window = 0.2` in TOML output, got:\n{toml}"
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
#[test]
|
| 162 |
+
fn test_f64_eviction_window_deserialize_round_trip() {
|
| 163 |
+
let fixture = Compact {
|
| 164 |
+
eviction_window: Percentage::new(0.2).unwrap(),
|
| 165 |
+
..Compact::new()
|
| 166 |
+
};
|
| 167 |
+
let config_fixture = ForgeConfig::default().compact(fixture.clone());
|
| 168 |
+
|
| 169 |
+
let toml = toml_edit::ser::to_string_pretty(&config_fixture).unwrap();
|
| 170 |
+
|
| 171 |
+
let actual = ConfigReader::default()
|
| 172 |
+
.read_defaults()
|
| 173 |
+
.read_toml(&toml)
|
| 174 |
+
.build()
|
| 175 |
+
.unwrap();
|
| 176 |
+
let actual = actual.compact.expect("compact config should deserialize");
|
| 177 |
+
|
| 178 |
+
assert_eq!(actual.eviction_window, fixture.eviction_window);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
#[test]
|
| 182 |
+
fn test_token_threshold_percentage_round_trip() {
|
| 183 |
+
let fixture = Compact {
|
| 184 |
+
token_threshold_percentage: Some(Percentage::new(0.7).unwrap()),
|
| 185 |
+
..Compact::new()
|
| 186 |
+
};
|
| 187 |
+
let config_fixture = ForgeConfig::default().compact(fixture.clone());
|
| 188 |
+
|
| 189 |
+
let toml = toml_edit::ser::to_string_pretty(&config_fixture).unwrap();
|
| 190 |
+
|
| 191 |
+
assert!(
|
| 192 |
+
toml.contains("token_threshold_percentage = 0.7\n"),
|
| 193 |
+
"expected `token_threshold_percentage = 0.7` in TOML output, got:\n{toml}"
|
| 194 |
+
);
|
| 195 |
+
|
| 196 |
+
let actual = ConfigReader::default()
|
| 197 |
+
.read_defaults()
|
| 198 |
+
.read_toml(&toml)
|
| 199 |
+
.build()
|
| 200 |
+
.unwrap();
|
| 201 |
+
let actual = actual.compact.expect("compact config should deserialize");
|
| 202 |
+
|
| 203 |
+
assert_eq!(
|
| 204 |
+
actual.token_threshold_percentage,
|
| 205 |
+
fixture.token_threshold_percentage
|
| 206 |
+
);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
#[test]
|
| 210 |
+
fn test_token_threshold_percentage_rejects_out_of_range() {
|
| 211 |
+
let toml = "[compact]\ntoken_threshold_percentage = 1.5\n";
|
| 212 |
+
|
| 213 |
+
let result = ConfigReader::default()
|
| 214 |
+
.read_defaults()
|
| 215 |
+
.read_toml(toml)
|
| 216 |
+
.build();
|
| 217 |
+
|
| 218 |
+
assert!(
|
| 219 |
+
result.is_err(),
|
| 220 |
+
"expected error for token_threshold_percentage = 1.5, got: {:?}",
|
| 221 |
+
result.ok()
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
#[test]
|
| 226 |
+
fn test_eviction_window_rejects_out_of_range() {
|
| 227 |
+
let toml = "[compact]\neviction_window = 1.5\n";
|
| 228 |
+
|
| 229 |
+
let result = ConfigReader::default()
|
| 230 |
+
.read_defaults()
|
| 231 |
+
.read_toml(toml)
|
| 232 |
+
.build();
|
| 233 |
+
|
| 234 |
+
assert!(
|
| 235 |
+
result.is_err(),
|
| 236 |
+
"expected error for eviction_window = 1.5, got: {:?}",
|
| 237 |
+
result.ok()
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
#[test]
|
| 242 |
+
fn test_update_frequency_never_round_trip() {
|
| 243 |
+
let fixture =
|
| 244 |
+
ForgeConfig::default().updates(Update::default().frequency(UpdateFrequency::Never));
|
| 245 |
+
|
| 246 |
+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 247 |
+
|
| 248 |
+
assert!(
|
| 249 |
+
toml.contains("frequency = \"never\"\n"),
|
| 250 |
+
"expected `frequency = \"never\"` in TOML output, got:\n{toml}"
|
| 251 |
+
);
|
| 252 |
+
|
| 253 |
+
let actual = ConfigReader::default()
|
| 254 |
+
.read_defaults()
|
| 255 |
+
.read_toml(&toml)
|
| 256 |
+
.build()
|
| 257 |
+
.unwrap();
|
| 258 |
+
|
| 259 |
+
let expected = Some(
|
| 260 |
+
Update::default()
|
| 261 |
+
.frequency(UpdateFrequency::Never)
|
| 262 |
+
.auto_update(true),
|
| 263 |
+
);
|
| 264 |
+
assert_eq!(actual.updates, expected);
|
| 265 |
+
}
|
| 266 |
+
}
|
crates/forge_config/src/config.rs
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::collections::HashMap;
|
| 2 |
+
use std::path::PathBuf;
|
| 3 |
+
|
| 4 |
+
use derive_setters::Setters;
|
| 5 |
+
use fake::Dummy;
|
| 6 |
+
use schemars::JsonSchema;
|
| 7 |
+
use serde::{Deserialize, Serialize};
|
| 8 |
+
|
| 9 |
+
use crate::reader::ConfigReader;
|
| 10 |
+
use crate::writer::ConfigWriter;
|
| 11 |
+
use crate::{
|
| 12 |
+
AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update,
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
/// Wire protocol a provider uses for chat completions.
|
| 16 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 17 |
+
pub enum ProviderResponseType {
|
| 18 |
+
OpenAI,
|
| 19 |
+
OpenAIResponses,
|
| 20 |
+
Anthropic,
|
| 21 |
+
Bedrock,
|
| 22 |
+
Google,
|
| 23 |
+
OpenCode,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/// Category of a provider.
|
| 27 |
+
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 28 |
+
#[serde(rename_all = "snake_case")]
|
| 29 |
+
pub enum ProviderTypeEntry {
|
| 30 |
+
/// LLM provider for chat completions.
|
| 31 |
+
#[default]
|
| 32 |
+
Llm,
|
| 33 |
+
/// Context engine provider for code indexing and search.
|
| 34 |
+
ContextEngine,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/// Authentication method supported by a provider.
|
| 38 |
+
///
|
| 39 |
+
/// Only the simple (non-OAuth) methods are available here; providers that
|
| 40 |
+
/// require OAuth device or authorization-code flows must be configured via the
|
| 41 |
+
/// file-based `provider.json` override instead.
|
| 42 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 43 |
+
#[serde(rename_all = "snake_case")]
|
| 44 |
+
pub enum ProviderAuthMethod {
|
| 45 |
+
ApiKey,
|
| 46 |
+
GoogleAdc,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/// A URL parameter variable for a provider, used to substitute template
|
| 50 |
+
/// variables in URL strings.
|
| 51 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 52 |
+
#[serde(rename_all = "snake_case")]
|
| 53 |
+
pub struct ProviderUrlParam {
|
| 54 |
+
/// The environment variable name used as the template variable key.
|
| 55 |
+
pub name: String,
|
| 56 |
+
/// Optional preset values for this parameter shown as suggestions in the
|
| 57 |
+
/// UI.
|
| 58 |
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
| 59 |
+
pub options: Vec<String>,
|
| 60 |
+
/// Whether this parameter is optional. When `true`, the parameter may be
|
| 61 |
+
/// left blank without causing an error.
|
| 62 |
+
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
| 63 |
+
pub optional: bool,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// Source of models for a provider: either a URL to fetch them from or a
|
| 67 |
+
/// static list defined inline.
|
| 68 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 69 |
+
#[serde(untagged)]
|
| 70 |
+
pub enum ModelListConfig {
|
| 71 |
+
/// URL template used to fetch the model list dynamically.
|
| 72 |
+
Url(String),
|
| 73 |
+
/// A static list of models defined directly in the configuration.
|
| 74 |
+
Hardcoded(Vec<forge_domain::Model>),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/// A single provider entry defined inline in `forge.toml`.
|
| 78 |
+
///
|
| 79 |
+
/// Inline providers are merged with the built-in provider list; entries with
|
| 80 |
+
/// the same `id` override the corresponding built-in entry field-by-field,
|
| 81 |
+
/// while entries with a new `id` are appended to the list.
|
| 82 |
+
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 83 |
+
#[serde(rename_all = "snake_case")]
|
| 84 |
+
pub struct ProviderEntry {
|
| 85 |
+
/// Unique provider identifier used in model paths (e.g. `"my_provider"`).
|
| 86 |
+
pub id: String,
|
| 87 |
+
/// Environment variable holding the API key for this provider.
|
| 88 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 89 |
+
pub api_key_var: Option<String>,
|
| 90 |
+
/// URL template for chat completions; may contain `{{VAR}}` placeholders
|
| 91 |
+
/// that are substituted from the credential's url params.
|
| 92 |
+
pub url: String,
|
| 93 |
+
/// Model source: either a URL template for dynamic discovery or a static
|
| 94 |
+
/// list of models defined inline.
|
| 95 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 96 |
+
pub models: Option<ModelListConfig>,
|
| 97 |
+
/// Wire protocol used by this provider.
|
| 98 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 99 |
+
pub response_type: Option<ProviderResponseType>,
|
| 100 |
+
/// Environment variables whose values are substituted into `{{VAR}}`
|
| 101 |
+
/// placeholders in the `url` and `models` templates.
|
| 102 |
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
| 103 |
+
pub url_param_vars: Vec<ProviderUrlParam>,
|
| 104 |
+
/// Additional HTTP headers sent with every request to this provider.
|
| 105 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 106 |
+
pub custom_headers: Option<HashMap<String, String>>,
|
| 107 |
+
/// Provider category; defaults to `llm` when omitted.
|
| 108 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 109 |
+
pub provider_type: Option<ProviderTypeEntry>,
|
| 110 |
+
/// Authentication methods supported by this provider; defaults to
|
| 111 |
+
/// `["api_key"]` when omitted.
|
| 112 |
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
| 113 |
+
pub auth_methods: Vec<ProviderAuthMethod>,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/// Top-level Forge configuration merged from all sources (defaults, file,
|
| 117 |
+
/// environment).
|
| 118 |
+
#[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
|
| 119 |
+
#[serde(rename_all = "snake_case")]
|
| 120 |
+
#[setters(strip_option)]
|
| 121 |
+
pub struct ForgeConfig {
|
| 122 |
+
/// Retry settings applied at the system level to all IO operations.
|
| 123 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 124 |
+
pub retry: Option<RetryConfig>,
|
| 125 |
+
/// Maximum number of lines returned by a single file search operation.
|
| 126 |
+
#[serde(default)]
|
| 127 |
+
pub max_search_lines: usize,
|
| 128 |
+
/// Maximum number of bytes returned by a single file search operation.
|
| 129 |
+
#[serde(default)]
|
| 130 |
+
pub max_search_result_bytes: usize,
|
| 131 |
+
/// Maximum number of characters returned from a URL fetch.
|
| 132 |
+
#[serde(default)]
|
| 133 |
+
pub max_fetch_chars: usize,
|
| 134 |
+
/// Maximum number of lines captured from the leading portion of shell
|
| 135 |
+
/// command output.
|
| 136 |
+
#[serde(default)]
|
| 137 |
+
pub max_stdout_prefix_lines: usize,
|
| 138 |
+
/// Maximum number of lines captured from the trailing portion of shell
|
| 139 |
+
/// command output.
|
| 140 |
+
#[serde(default)]
|
| 141 |
+
pub max_stdout_suffix_lines: usize,
|
| 142 |
+
/// Maximum number of characters per line in shell command output.
|
| 143 |
+
#[serde(default)]
|
| 144 |
+
pub max_stdout_line_chars: usize,
|
| 145 |
+
/// Maximum number of characters per line when reading a file.
|
| 146 |
+
#[serde(default)]
|
| 147 |
+
pub max_line_chars: usize,
|
| 148 |
+
/// Maximum number of lines read from a file in a single operation.
|
| 149 |
+
#[serde(default)]
|
| 150 |
+
pub max_read_lines: u64,
|
| 151 |
+
/// Maximum number of files read in a single batch operation.
|
| 152 |
+
#[serde(default)]
|
| 153 |
+
pub max_file_read_batch_size: usize,
|
| 154 |
+
/// HTTP client settings including proxy, TLS, and timeout configuration.
|
| 155 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 156 |
+
pub http: Option<HttpConfig>,
|
| 157 |
+
/// Maximum file size in bytes permitted for read operations.
|
| 158 |
+
#[serde(default)]
|
| 159 |
+
pub max_file_size_bytes: u64,
|
| 160 |
+
/// Maximum image file size in bytes permitted for read operations.
|
| 161 |
+
#[serde(default)]
|
| 162 |
+
pub max_image_size_bytes: u64,
|
| 163 |
+
/// Maximum time in seconds a single tool call may run before being
|
| 164 |
+
/// cancelled.
|
| 165 |
+
#[serde(default)]
|
| 166 |
+
pub tool_timeout_secs: u64,
|
| 167 |
+
/// Whether to automatically open HTML dump files in the browser after
|
| 168 |
+
/// creation.
|
| 169 |
+
#[serde(default)]
|
| 170 |
+
pub auto_open_dump: bool,
|
| 171 |
+
/// Directory where debug request files are written; disabled when absent.
|
| 172 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 173 |
+
pub debug_requests: Option<PathBuf>,
|
| 174 |
+
/// Path to the conversation history file; defaults to the global history
|
| 175 |
+
/// location when absent.
|
| 176 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 177 |
+
pub custom_history_path: Option<PathBuf>,
|
| 178 |
+
/// Maximum number of conversations shown in the conversation list.
|
| 179 |
+
#[serde(default)]
|
| 180 |
+
pub max_conversations: usize,
|
| 181 |
+
/// Maximum number of candidate results returned from the initial semantic
|
| 182 |
+
/// search vector query.
|
| 183 |
+
#[serde(default)]
|
| 184 |
+
pub max_sem_search_results: usize,
|
| 185 |
+
/// Number of top results retained after re-ranking in semantic search.
|
| 186 |
+
#[serde(default)]
|
| 187 |
+
pub sem_search_top_k: usize,
|
| 188 |
+
/// Base URL of the Forge services API used for semantic search and
|
| 189 |
+
/// indexing.
|
| 190 |
+
#[serde(default)]
|
| 191 |
+
#[dummy(expr = "\"https://api.forgecode.dev/api\".to_string()")]
|
| 192 |
+
pub services_url: String,
|
| 193 |
+
/// Maximum number of file extensions included in the agent system prompt.
|
| 194 |
+
#[serde(default)]
|
| 195 |
+
pub max_extensions: usize,
|
| 196 |
+
/// Format used when automatically creating a session dump after task
|
| 197 |
+
/// completion; disabled when absent.
|
| 198 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 199 |
+
pub auto_dump: Option<AutoDumpFormat>,
|
| 200 |
+
/// Maximum number of files read concurrently during batch operations.
|
| 201 |
+
#[serde(default)]
|
| 202 |
+
pub max_parallel_file_reads: usize,
|
| 203 |
+
/// Time-to-live in seconds for the cached model API list.
|
| 204 |
+
#[serde(default)]
|
| 205 |
+
pub model_cache_ttl_secs: u64,
|
| 206 |
+
/// Default model and provider configuration used when not overridden by
|
| 207 |
+
/// individual agents.
|
| 208 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 209 |
+
pub session: Option<ModelConfig>,
|
| 210 |
+
/// Model and provider configuration used for commit message generation.
|
| 211 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 212 |
+
pub commit: Option<ModelConfig>,
|
| 213 |
+
/// Whether `forge commit` should override `GIT_COMMITTER_NAME` and
|
| 214 |
+
/// `GIT_COMMITTER_EMAIL` with the Forge identity. Defaults to `true` via
|
| 215 |
+
/// the embedded `.forge.toml` defaults.
|
| 216 |
+
#[serde(default)]
|
| 217 |
+
pub use_forge_committer: bool,
|
| 218 |
+
/// Maximum number of recent commits included as context for commit message
|
| 219 |
+
/// generation.
|
| 220 |
+
#[serde(default)]
|
| 221 |
+
pub max_commit_count: usize,
|
| 222 |
+
/// Model and provider configuration used for shell command suggestion
|
| 223 |
+
/// generation.
|
| 224 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 225 |
+
pub suggest: Option<ModelConfig>,
|
| 226 |
+
|
| 227 |
+
// --- Workflow fields ---
|
| 228 |
+
/// Configuration for automatic Forge updates.
|
| 229 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 230 |
+
pub updates: Option<Update>,
|
| 231 |
+
|
| 232 |
+
/// Output randomness for all agents; lower values are deterministic, higher
|
| 233 |
+
/// values are creative (0.0–2.0).
|
| 234 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 235 |
+
pub temperature: Option<Decimal>,
|
| 236 |
+
|
| 237 |
+
/// Nucleus sampling threshold for all agents; limits token selection to the
|
| 238 |
+
/// top cumulative probability mass (0.0–1.0).
|
| 239 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 240 |
+
pub top_p: Option<Decimal>,
|
| 241 |
+
|
| 242 |
+
/// Top-k vocabulary cutoff for all agents; restricts sampling to the k
|
| 243 |
+
/// highest-probability tokens (1–1000).
|
| 244 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 245 |
+
pub top_k: Option<u32>,
|
| 246 |
+
|
| 247 |
+
/// Maximum tokens the model may generate per response for all agents
|
| 248 |
+
/// (1–100,000).
|
| 249 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 250 |
+
pub max_tokens: Option<u32>,
|
| 251 |
+
|
| 252 |
+
/// Maximum tool failures per turn before the orchestrator forces
|
| 253 |
+
/// completion.
|
| 254 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 255 |
+
pub max_tool_failure_per_turn: Option<usize>,
|
| 256 |
+
|
| 257 |
+
/// Maximum number of requests that can be made in a single turn.
|
| 258 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 259 |
+
pub max_requests_per_turn: Option<usize>,
|
| 260 |
+
|
| 261 |
+
/// Context compaction settings applied to all agents; falls back to each
|
| 262 |
+
/// agent's individual setting when absent.
|
| 263 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 264 |
+
pub compact: Option<Compact>,
|
| 265 |
+
|
| 266 |
+
/// Whether restricted mode is active; when enabled, tool execution requires
|
| 267 |
+
/// explicit permission grants.
|
| 268 |
+
#[serde(default)]
|
| 269 |
+
pub restricted: bool,
|
| 270 |
+
|
| 271 |
+
/// Whether tool use is supported in the current environment; when false,
|
| 272 |
+
/// all tool calls are disabled.
|
| 273 |
+
#[serde(default)]
|
| 274 |
+
pub tool_supported: bool,
|
| 275 |
+
|
| 276 |
+
/// Reasoning configuration applied to all agents; controls effort level,
|
| 277 |
+
/// token budget, and visibility of the model's thinking process.
|
| 278 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 279 |
+
pub reasoning: Option<ReasoningConfig>,
|
| 280 |
+
|
| 281 |
+
/// Additional provider definitions merged with the built-in provider list.
|
| 282 |
+
///
|
| 283 |
+
/// Entries with an `id` matching a built-in provider override its fields;
|
| 284 |
+
/// entries with a new `id` are appended and become available for model
|
| 285 |
+
/// selection.
|
| 286 |
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
| 287 |
+
pub providers: Vec<ProviderEntry>,
|
| 288 |
+
|
| 289 |
+
/// Currency symbol displayed in the shell rprompt next to the session cost
|
| 290 |
+
/// (e.g. `"$"`, `"€"`, `"₹"`). Defaults to `"$"`.
|
| 291 |
+
#[serde(default)]
|
| 292 |
+
pub currency_symbol: String,
|
| 293 |
+
|
| 294 |
+
/// Conversion rate applied to costs before display in the shell rprompt.
|
| 295 |
+
/// The raw USD cost is multiplied by this value, allowing costs to be shown
|
| 296 |
+
/// in a local currency. Defaults to `1.0` (no conversion).
|
| 297 |
+
#[serde(default)]
|
| 298 |
+
pub currency_conversion_rate: Decimal,
|
| 299 |
+
|
| 300 |
+
/// Enables the pending todos hook that checks for incomplete todo items
|
| 301 |
+
/// when a task ends and reminds the LLM about them.
|
| 302 |
+
#[serde(default)]
|
| 303 |
+
pub verify_todos: bool,
|
| 304 |
+
|
| 305 |
+
/// Switches patch replacement fallback from the legacy fuzzy-search range
|
| 306 |
+
/// lookup to the newer text-patch gRPC API.
|
| 307 |
+
/// Defaults to `false` so patching continues to use the legacy fallback
|
| 308 |
+
/// behavior unless explicitly enabled in `forge.toml`.
|
| 309 |
+
#[serde(default)]
|
| 310 |
+
pub use_text_patch_fallback: bool,
|
| 311 |
+
|
| 312 |
+
/// Whether the deep research agent is available.
|
| 313 |
+
///
|
| 314 |
+
/// When set to `true`, the Sage agent is added to the agent list and
|
| 315 |
+
/// the `:sage` app command is enabled. Defaults to `false`.
|
| 316 |
+
#[serde(default)]
|
| 317 |
+
pub research_subagent: bool,
|
| 318 |
+
|
| 319 |
+
/// Enables subagent support via the task tool; when true the forge agent
|
| 320 |
+
/// gains access to the `task` tool for delegating work to specialised
|
| 321 |
+
/// sub-agents, and the `sage` research-only agent tool is removed.
|
| 322 |
+
/// When false the `task` tool is disabled and `sage` is available instead.
|
| 323 |
+
#[serde(default)]
|
| 324 |
+
pub subagents: bool,
|
| 325 |
+
|
| 326 |
+
/// Enables automatic VS Code extension installation when Forge runs inside
|
| 327 |
+
/// VS Code and the extension is not already installed.
|
| 328 |
+
#[serde(default)]
|
| 329 |
+
pub auto_install_vscode_extension: bool,
|
| 330 |
+
|
| 331 |
+
/// When `true`, all system messages in the conversation are merged into a
|
| 332 |
+
/// single leading system message before the request is sent. Enable this
|
| 333 |
+
/// for providers that reject requests containing system messages after
|
| 334 |
+
/// user or assistant turns (e.g. vLLM, NVIDIA NIM).
|
| 335 |
+
#[serde(default)]
|
| 336 |
+
pub merge_system_messages: bool,
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
impl ForgeConfig {
|
| 340 |
+
/// Reads and merges configuration from all sources, returning the resolved
|
| 341 |
+
/// [`ForgeConfig`].
|
| 342 |
+
///
|
| 343 |
+
/// # Errors
|
| 344 |
+
///
|
| 345 |
+
/// Returns an error if the config path cannot be resolved, the file cannot
|
| 346 |
+
/// be read, or deserialization fails.
|
| 347 |
+
pub fn read() -> crate::Result<ForgeConfig> {
|
| 348 |
+
ConfigReader::default()
|
| 349 |
+
.read_legacy()
|
| 350 |
+
.read_defaults()
|
| 351 |
+
.read_global()
|
| 352 |
+
.read_env()
|
| 353 |
+
.build()
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
/// Writes the configuration to the user config file.
|
| 357 |
+
///
|
| 358 |
+
/// # Errors
|
| 359 |
+
///
|
| 360 |
+
/// Returns an error if the configuration cannot be serialized or written to
|
| 361 |
+
/// disk.
|
| 362 |
+
pub fn write(&self) -> crate::Result<()> {
|
| 363 |
+
let path = ConfigReader::config_path();
|
| 364 |
+
ConfigWriter::new(self.clone()).write(&path)
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
#[cfg(test)]
|
| 369 |
+
mod tests {
|
| 370 |
+
use pretty_assertions::assert_eq;
|
| 371 |
+
|
| 372 |
+
use super::*;
|
| 373 |
+
use crate::reader::ConfigReader;
|
| 374 |
+
|
| 375 |
+
#[test]
|
| 376 |
+
fn test_f32_temperature_round_trip() {
|
| 377 |
+
let fixture = ForgeConfig { temperature: Some(Decimal(0.1)), ..Default::default() };
|
| 378 |
+
|
| 379 |
+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 380 |
+
|
| 381 |
+
assert!(
|
| 382 |
+
toml.contains("temperature = 0.1\n"),
|
| 383 |
+
"expected `temperature = 0.1` in TOML output, got:\n{toml}"
|
| 384 |
+
);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
#[test]
|
| 388 |
+
fn test_f32_top_p_round_trip() {
|
| 389 |
+
let fixture = ForgeConfig { top_p: Some(Decimal(0.9)), ..Default::default() };
|
| 390 |
+
|
| 391 |
+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 392 |
+
|
| 393 |
+
assert!(
|
| 394 |
+
toml.contains("top_p = 0.9\n"),
|
| 395 |
+
"expected `top_p = 0.9` in TOML output, got:\n{toml}"
|
| 396 |
+
);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
#[test]
|
| 400 |
+
fn test_f32_temperature_deserialize_round_trip() {
|
| 401 |
+
let fixture = ForgeConfig { temperature: Some(Decimal(0.1)), ..Default::default() };
|
| 402 |
+
|
| 403 |
+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 404 |
+
|
| 405 |
+
let actual = ConfigReader::default().read_toml(&toml).build().unwrap();
|
| 406 |
+
|
| 407 |
+
assert_eq!(actual.temperature, fixture.temperature);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
#[test]
|
| 411 |
+
fn test_provider_static_model_list_deserialization() {
|
| 412 |
+
let fixture = r#"
|
| 413 |
+
[[providers]]
|
| 414 |
+
id = "ollama"
|
| 415 |
+
url = "http://127.0.0.1:8000/v1/chat/completions"
|
| 416 |
+
response_type = "OpenAI"
|
| 417 |
+
auth_methods = ["api_key"]
|
| 418 |
+
|
| 419 |
+
[[providers.models]]
|
| 420 |
+
id = "Qwen3.6-35B-A3b-q3-mlx"
|
| 421 |
+
name = "Qwen3.5-35B"
|
| 422 |
+
description = "Qwen local reasoning model with advanced problem-solving capabilities"
|
| 423 |
+
context_length = 262144
|
| 424 |
+
tools_supported = true
|
| 425 |
+
supports_parallel_tool_calls = true
|
| 426 |
+
supports_reasoning = true
|
| 427 |
+
input_modalities = ["text"]
|
| 428 |
+
|
| 429 |
+
[[providers.models]]
|
| 430 |
+
id = "llama3.2-3b"
|
| 431 |
+
name = "Llama 3.2 3B"
|
| 432 |
+
description = "Meta Llama 3.2 3B lightweight local model"
|
| 433 |
+
context_length = 131072
|
| 434 |
+
tools_supported = true
|
| 435 |
+
supports_parallel_tool_calls = false
|
| 436 |
+
supports_reasoning = false
|
| 437 |
+
input_modalities = ["text"]
|
| 438 |
+
"#;
|
| 439 |
+
|
| 440 |
+
let actual = ConfigReader::default().read_toml(fixture).build().unwrap();
|
| 441 |
+
|
| 442 |
+
let expected = vec![ProviderEntry {
|
| 443 |
+
id: "ollama".to_string(),
|
| 444 |
+
url: "http://127.0.0.1:8000/v1/chat/completions".to_string(),
|
| 445 |
+
response_type: Some(ProviderResponseType::OpenAI),
|
| 446 |
+
auth_methods: vec![ProviderAuthMethod::ApiKey],
|
| 447 |
+
models: Some(ModelListConfig::Hardcoded(vec![
|
| 448 |
+
forge_domain::Model::new("Qwen3.6-35B-A3b-q3-mlx")
|
| 449 |
+
.name("Qwen3.5-35B".to_string())
|
| 450 |
+
.description(
|
| 451 |
+
"Qwen local reasoning model with advanced problem-solving capabilities"
|
| 452 |
+
.to_string(),
|
| 453 |
+
)
|
| 454 |
+
.context_length(262144)
|
| 455 |
+
.tools_supported(true)
|
| 456 |
+
.supports_parallel_tool_calls(true)
|
| 457 |
+
.supports_reasoning(true)
|
| 458 |
+
.input_modalities(vec![forge_domain::InputModality::Text]),
|
| 459 |
+
forge_domain::Model::new("llama3.2-3b")
|
| 460 |
+
.name("Llama 3.2 3B".to_string())
|
| 461 |
+
.description("Meta Llama 3.2 3B lightweight local model".to_string())
|
| 462 |
+
.context_length(131072)
|
| 463 |
+
.tools_supported(true)
|
| 464 |
+
.supports_parallel_tool_calls(false)
|
| 465 |
+
.supports_reasoning(false)
|
| 466 |
+
.input_modalities(vec![forge_domain::InputModality::Text]),
|
| 467 |
+
])),
|
| 468 |
+
..Default::default()
|
| 469 |
+
}];
|
| 470 |
+
|
| 471 |
+
assert_eq!(actual.providers, expected);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
#[test]
|
| 475 |
+
fn test_provider_url_model_list_deserialization() {
|
| 476 |
+
let fixture = r#"
|
| 477 |
+
[[providers]]
|
| 478 |
+
id = "my_provider"
|
| 479 |
+
url = "http://example.com/v1/chat/completions"
|
| 480 |
+
models = "http://example.com/v1/models"
|
| 481 |
+
"#;
|
| 482 |
+
|
| 483 |
+
let actual = ConfigReader::default().read_toml(fixture).build().unwrap();
|
| 484 |
+
|
| 485 |
+
let expected = vec![ProviderEntry {
|
| 486 |
+
id: "my_provider".to_string(),
|
| 487 |
+
url: "http://example.com/v1/chat/completions".to_string(),
|
| 488 |
+
models: Some(ModelListConfig::Url(
|
| 489 |
+
"http://example.com/v1/models".to_string(),
|
| 490 |
+
)),
|
| 491 |
+
..Default::default()
|
| 492 |
+
}];
|
| 493 |
+
|
| 494 |
+
assert_eq!(actual.providers, expected);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
#[test]
|
| 498 |
+
fn test_auto_install_vscode_extension_defaults_to_true() {
|
| 499 |
+
let actual = ConfigReader::default().read_defaults().build().unwrap();
|
| 500 |
+
|
| 501 |
+
assert_eq!(actual.auto_install_vscode_extension, true);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
#[test]
|
| 505 |
+
fn test_auto_install_vscode_extension_can_be_disabled() {
|
| 506 |
+
let toml = "auto_install_vscode_extension = false\n";
|
| 507 |
+
|
| 508 |
+
let actual = ConfigReader::default()
|
| 509 |
+
.read_defaults()
|
| 510 |
+
.read_toml(toml)
|
| 511 |
+
.build()
|
| 512 |
+
.unwrap();
|
| 513 |
+
|
| 514 |
+
assert_eq!(actual.auto_install_vscode_extension, false);
|
| 515 |
+
}
|
| 516 |
+
}
|
crates/forge_config/src/decimal.rs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// A floating-point newtype that serializes to two decimal places, preventing
|
| 2 |
+
/// `toml_edit` from emitting noisy bit-pattern approximations such as
|
| 3 |
+
/// `0.10000000149011612` or `0.20000000000000001`.
|
| 4 |
+
///
|
| 5 |
+
/// The inner value is stored as `f64`. When used for fields that ultimately
|
| 6 |
+
/// require `f32`, callers should cast via `value() as f32`.
|
| 7 |
+
pub struct Decimal(pub f64);
|
| 8 |
+
|
| 9 |
+
impl Decimal {
|
| 10 |
+
/// Returns the inner `f64` value.
|
| 11 |
+
pub fn value(&self) -> f64 {
|
| 12 |
+
self.0
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
impl std::fmt::Debug for Decimal {
|
| 17 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 18 |
+
self.0.fmt(f)
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
impl Clone for Decimal {
|
| 23 |
+
fn clone(&self) -> Self {
|
| 24 |
+
*self
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
impl Copy for Decimal {}
|
| 29 |
+
|
| 30 |
+
impl PartialEq for Decimal {
|
| 31 |
+
fn eq(&self, other: &Self) -> bool {
|
| 32 |
+
self.0 == other.0
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
impl PartialOrd for Decimal {
|
| 37 |
+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
| 38 |
+
self.0.partial_cmp(&other.0)
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
impl Default for Decimal {
|
| 43 |
+
fn default() -> Self {
|
| 44 |
+
Self(0.0)
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
impl From<f64> for Decimal {
|
| 49 |
+
fn from(v: f64) -> Self {
|
| 50 |
+
Self(v)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
impl From<f32> for Decimal {
|
| 55 |
+
fn from(v: f32) -> Self {
|
| 56 |
+
Self(v as f64)
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
impl From<Decimal> for f64 {
|
| 61 |
+
fn from(d: Decimal) -> Self {
|
| 62 |
+
d.0
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
impl schemars::JsonSchema for Decimal {
|
| 67 |
+
fn schema_name() -> std::borrow::Cow<'static, str> {
|
| 68 |
+
f64::schema_name()
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
fn json_schema(r#gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
|
| 72 |
+
f64::json_schema(r#gen)
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
impl fake::Dummy<fake::Faker> for Decimal {
|
| 77 |
+
fn dummy_with_rng<R: fake::RngExt + ?Sized>(_: &fake::Faker, rng: &mut R) -> Self {
|
| 78 |
+
use fake::Fake;
|
| 79 |
+
Self((0.0f64..2.0f64).fake_with_rng(rng))
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
impl serde::Serialize for Decimal {
|
| 84 |
+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
| 85 |
+
let formatted: f64 = format!("{:.2}", self.0).parse().unwrap();
|
| 86 |
+
serializer.serialize_f64(formatted)
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
impl<'de> serde::Deserialize<'de> for Decimal {
|
| 91 |
+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
| 92 |
+
Ok(Self(f64::deserialize(deserializer)?))
|
| 93 |
+
}
|
| 94 |
+
}
|
crates/forge_config/src/error.rs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// Errors produced by the `forge_config` crate.
|
| 2 |
+
#[derive(Debug, thiserror::Error)]
|
| 3 |
+
pub enum Error {
|
| 4 |
+
/// Failed to read or parse configuration from a file or environment.
|
| 5 |
+
#[error("Config error: {0}")]
|
| 6 |
+
Config(#[from] config::ConfigError),
|
| 7 |
+
|
| 8 |
+
/// Failed to serialize or write configuration.
|
| 9 |
+
#[error("Serialization error: {0}")]
|
| 10 |
+
Serialization(#[from] toml_edit::ser::Error),
|
| 11 |
+
|
| 12 |
+
/// An I/O error occurred while reading or writing configuration files.
|
| 13 |
+
#[error("IO error: {0}")]
|
| 14 |
+
Io(#[from] std::io::Error),
|
| 15 |
+
|
| 16 |
+
#[error("JSON error: {0}")]
|
| 17 |
+
Serde(#[from] serde_json::Error),
|
| 18 |
+
}
|
crates/forge_config/src/http.rs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use schemars::JsonSchema;
|
| 2 |
+
use serde::{Deserialize, Serialize};
|
| 3 |
+
|
| 4 |
+
/// TLS version enum for configuring TLS protocol versions.
|
| 5 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)]
|
| 6 |
+
#[serde(rename_all = "snake_case")]
|
| 7 |
+
pub enum TlsVersion {
|
| 8 |
+
#[serde(rename = "1.0")]
|
| 9 |
+
V1_0,
|
| 10 |
+
#[serde(rename = "1.1")]
|
| 11 |
+
V1_1,
|
| 12 |
+
#[serde(rename = "1.2")]
|
| 13 |
+
V1_2,
|
| 14 |
+
#[serde(rename = "1.3")]
|
| 15 |
+
V1_3,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/// TLS backend option.
|
| 19 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)]
|
| 20 |
+
#[serde(rename_all = "snake_case")]
|
| 21 |
+
pub enum TlsBackend {
|
| 22 |
+
#[serde(rename = "default")]
|
| 23 |
+
Default,
|
| 24 |
+
#[serde(rename = "rustls")]
|
| 25 |
+
Rustls,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/// HTTP client configuration.
|
| 29 |
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)]
|
| 30 |
+
#[serde(rename_all = "snake_case")]
|
| 31 |
+
pub struct HttpConfig {
|
| 32 |
+
pub connect_timeout_secs: u64,
|
| 33 |
+
pub read_timeout_secs: u64,
|
| 34 |
+
pub pool_idle_timeout_secs: u64,
|
| 35 |
+
pub pool_max_idle_per_host: usize,
|
| 36 |
+
pub max_redirects: usize,
|
| 37 |
+
pub hickory: bool,
|
| 38 |
+
pub tls_backend: TlsBackend,
|
| 39 |
+
/// Minimum TLS protocol version to use
|
| 40 |
+
pub min_tls_version: Option<TlsVersion>,
|
| 41 |
+
/// Maximum TLS protocol version to use
|
| 42 |
+
pub max_tls_version: Option<TlsVersion>,
|
| 43 |
+
/// Adaptive window sizing for improved flow control
|
| 44 |
+
pub adaptive_window: bool,
|
| 45 |
+
/// Keep-alive interval in seconds
|
| 46 |
+
pub keep_alive_interval_secs: Option<u64>,
|
| 47 |
+
/// Keep-alive timeout in seconds
|
| 48 |
+
pub keep_alive_timeout_secs: u64,
|
| 49 |
+
/// Keep-alive while connection is idle
|
| 50 |
+
pub keep_alive_while_idle: bool,
|
| 51 |
+
/// Accept invalid certificates
|
| 52 |
+
pub accept_invalid_certs: bool,
|
| 53 |
+
/// Paths to root certificate files
|
| 54 |
+
pub root_cert_paths: Option<Vec<String>>,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[cfg(test)]
|
| 58 |
+
mod tests {
|
| 59 |
+
use pretty_assertions::assert_eq;
|
| 60 |
+
|
| 61 |
+
use super::*;
|
| 62 |
+
|
| 63 |
+
#[test]
|
| 64 |
+
fn test_http_config_fields() {
|
| 65 |
+
let config = HttpConfig {
|
| 66 |
+
connect_timeout_secs: 30,
|
| 67 |
+
read_timeout_secs: 900,
|
| 68 |
+
pool_idle_timeout_secs: 90,
|
| 69 |
+
pool_max_idle_per_host: 5,
|
| 70 |
+
max_redirects: 10,
|
| 71 |
+
hickory: false,
|
| 72 |
+
tls_backend: TlsBackend::Default,
|
| 73 |
+
min_tls_version: None,
|
| 74 |
+
max_tls_version: None,
|
| 75 |
+
adaptive_window: true,
|
| 76 |
+
keep_alive_interval_secs: Some(60),
|
| 77 |
+
keep_alive_timeout_secs: 10,
|
| 78 |
+
keep_alive_while_idle: true,
|
| 79 |
+
accept_invalid_certs: false,
|
| 80 |
+
root_cert_paths: None,
|
| 81 |
+
};
|
| 82 |
+
assert_eq!(config.connect_timeout_secs, 30);
|
| 83 |
+
assert_eq!(config.adaptive_window, true);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
#[test]
|
| 87 |
+
fn test_tls_version_variants() {
|
| 88 |
+
assert_eq!(TlsVersion::V1_3, TlsVersion::V1_3);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
#[test]
|
| 92 |
+
fn test_tls_backend_variants() {
|
| 93 |
+
assert_eq!(TlsBackend::Default, TlsBackend::Default);
|
| 94 |
+
}
|
| 95 |
+
}
|
crates/forge_config/src/legacy.rs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::collections::HashMap;
|
| 2 |
+
use std::path::PathBuf;
|
| 3 |
+
|
| 4 |
+
use serde::Deserialize;
|
| 5 |
+
|
| 6 |
+
use crate::{ForgeConfig, ModelConfig};
|
| 7 |
+
|
| 8 |
+
/// Intermediate representation of the legacy `~/forge/.config.json` format.
|
| 9 |
+
///
|
| 10 |
+
/// This format stores the active provider as a top-level string and models as
|
| 11 |
+
/// a map from provider ID to model ID, which differs from the TOML config's
|
| 12 |
+
/// nested `session`, `commit`, and `suggest` sub-objects.
|
| 13 |
+
#[derive(Debug, Deserialize)]
|
| 14 |
+
#[serde(rename_all = "camelCase")]
|
| 15 |
+
pub(crate) struct LegacyConfig {
|
| 16 |
+
/// The active provider ID (e.g. `"anthropic"`).
|
| 17 |
+
#[serde(default)]
|
| 18 |
+
provider: Option<String>,
|
| 19 |
+
/// Map from provider ID to the model ID to use with that provider.
|
| 20 |
+
#[serde(default)]
|
| 21 |
+
model: HashMap<String, String>,
|
| 22 |
+
/// Commit message generation provider/model pair.
|
| 23 |
+
#[serde(default)]
|
| 24 |
+
commit: Option<LegacyModelRef>,
|
| 25 |
+
/// Shell command suggestion provider/model pair.
|
| 26 |
+
#[serde(default)]
|
| 27 |
+
suggest: Option<LegacyModelRef>,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/// A provider/model pair as expressed in the legacy JSON config.
|
| 31 |
+
#[derive(Debug, Deserialize)]
|
| 32 |
+
struct LegacyModelRef {
|
| 33 |
+
provider: Option<String>,
|
| 34 |
+
model: Option<String>,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
impl LegacyConfig {
|
| 38 |
+
/// Reads the legacy `~/forge/.config.json` file at `path`, parses it, and
|
| 39 |
+
/// returns the equivalent TOML representation as a [`String`].
|
| 40 |
+
///
|
| 41 |
+
/// Because every field in [`ForgeConfig`] is `Option`, fields not covered
|
| 42 |
+
/// by the legacy format are `None` and omitted from the serialized TOML,
|
| 43 |
+
/// so they cannot overwrite values from lower-priority config layers.
|
| 44 |
+
///
|
| 45 |
+
/// # Errors
|
| 46 |
+
///
|
| 47 |
+
/// Returns an error if the file cannot be read, the JSON is invalid, or the
|
| 48 |
+
/// resulting config cannot be serialized to TOML.
|
| 49 |
+
pub(crate) fn read(path: &PathBuf) -> crate::Result<String> {
|
| 50 |
+
let contents = std::fs::read_to_string(path)?;
|
| 51 |
+
let config = serde_json::from_str::<LegacyConfig>(&contents)?;
|
| 52 |
+
let forge_config = config.into_forge_config();
|
| 53 |
+
let content = toml_edit::ser::to_string_pretty(&forge_config)?;
|
| 54 |
+
Ok(content)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it
|
| 58 |
+
/// covers, leaving all other fields at their defaults (`None`).
|
| 59 |
+
fn into_forge_config(self) -> ForgeConfig {
|
| 60 |
+
let session = self.provider.as_deref().and_then(|provider_id| {
|
| 61 |
+
self.model
|
| 62 |
+
.get(provider_id)
|
| 63 |
+
.cloned()
|
| 64 |
+
.map(|model_id| ModelConfig { provider_id: provider_id.to_string(), model_id })
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
let commit = self.commit.and_then(|c| {
|
| 68 |
+
c.provider
|
| 69 |
+
.zip(c.model)
|
| 70 |
+
.map(|(provider_id, model_id)| ModelConfig { provider_id, model_id })
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
let suggest = self.suggest.and_then(|s| {
|
| 74 |
+
s.provider
|
| 75 |
+
.zip(s.model)
|
| 76 |
+
.map(|(provider_id, model_id)| ModelConfig { provider_id, model_id })
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
ForgeConfig { session, commit, suggest, ..Default::default() }
|
| 80 |
+
}
|
| 81 |
+
}
|
crates/forge_config/src/lib.rs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod auto_dump;
|
| 2 |
+
mod compact;
|
| 3 |
+
mod config;
|
| 4 |
+
mod decimal;
|
| 5 |
+
mod error;
|
| 6 |
+
mod http;
|
| 7 |
+
mod legacy;
|
| 8 |
+
mod model;
|
| 9 |
+
mod percentage;
|
| 10 |
+
mod reader;
|
| 11 |
+
mod reasoning;
|
| 12 |
+
mod retry;
|
| 13 |
+
mod writer;
|
| 14 |
+
|
| 15 |
+
pub use auto_dump::*;
|
| 16 |
+
pub use compact::*;
|
| 17 |
+
pub use config::*;
|
| 18 |
+
pub use decimal::*;
|
| 19 |
+
pub use error::Error;
|
| 20 |
+
pub use http::*;
|
| 21 |
+
pub use model::*;
|
| 22 |
+
pub use percentage::*;
|
| 23 |
+
pub use reader::*;
|
| 24 |
+
pub use reasoning::*;
|
| 25 |
+
pub use retry::*;
|
| 26 |
+
pub use writer::*;
|
| 27 |
+
|
| 28 |
+
/// A `Result` type alias for this crate's [`Error`] type.
|
| 29 |
+
pub type Result<T> = std::result::Result<T, Error>;
|
crates/forge_config/src/model.rs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use schemars::JsonSchema;
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
|
| 5 |
+
/// A type alias for a provider identifier string.
|
| 6 |
+
pub type ProviderId = String;
|
| 7 |
+
|
| 8 |
+
/// A type alias for a model identifier string.
|
| 9 |
+
pub type ModelId = String;
|
| 10 |
+
|
| 11 |
+
/// Pairs a provider and model together for a specific operation.
|
| 12 |
+
#[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)]
|
| 13 |
+
#[setters(into)]
|
| 14 |
+
pub struct ModelConfig {
|
| 15 |
+
/// The provider to use for this operation.
|
| 16 |
+
pub provider_id: String,
|
| 17 |
+
/// The model to use for this operation.
|
| 18 |
+
pub model_id: String,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
impl ModelConfig {
|
| 22 |
+
/// Creates a new ModelConfig with the given provider and model IDs.
|
| 23 |
+
pub fn new(provider_id: impl Into<String>, model_id: impl Into<String>) -> Self {
|
| 24 |
+
Self { provider_id: provider_id.into(), model_id: model_id.into() }
|
| 25 |
+
}
|
| 26 |
+
}
|
crates/forge_config/src/percentage.rs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::Decimal;
|
| 2 |
+
|
| 3 |
+
/// A percentage value constrained to `[0.0, 1.0]`, built on top of [`Decimal`]
|
| 4 |
+
/// to inherit its two-decimal-place serialization and clean TOML output.
|
| 5 |
+
///
|
| 6 |
+
/// Validation is enforced at deserialization time, so any config file with an
|
| 7 |
+
/// out-of-range value is rejected with a descriptive error.
|
| 8 |
+
#[derive(Default)]
|
| 9 |
+
pub struct Percentage(Decimal);
|
| 10 |
+
|
| 11 |
+
impl Percentage {
|
| 12 |
+
const MIN: f64 = 0.0;
|
| 13 |
+
const MAX: f64 = 1.0;
|
| 14 |
+
|
| 15 |
+
/// Construct a validated `Percentage`.
|
| 16 |
+
///
|
| 17 |
+
/// # Errors
|
| 18 |
+
///
|
| 19 |
+
/// Returns `Err` if `value` is outside `[0.0, 1.0]`.
|
| 20 |
+
pub fn new(value: f64) -> Result<Self, String> {
|
| 21 |
+
if (Self::MIN..=Self::MAX).contains(&value) {
|
| 22 |
+
Ok(Self(Decimal(value)))
|
| 23 |
+
} else {
|
| 24 |
+
Err(format!(
|
| 25 |
+
"value must be between {} and {}, got {value}",
|
| 26 |
+
Self::MIN,
|
| 27 |
+
Self::MAX
|
| 28 |
+
))
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/// Returns the inner `f64` value.
|
| 33 |
+
pub fn value(&self) -> f64 {
|
| 34 |
+
self.0.value()
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
impl std::fmt::Debug for Percentage {
|
| 39 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 40 |
+
self.0.fmt(f)
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
impl Clone for Percentage {
|
| 45 |
+
fn clone(&self) -> Self {
|
| 46 |
+
*self
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
impl Copy for Percentage {}
|
| 51 |
+
|
| 52 |
+
impl PartialEq for Percentage {
|
| 53 |
+
fn eq(&self, other: &Self) -> bool {
|
| 54 |
+
self.0 == other.0
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
impl PartialOrd for Percentage {
|
| 59 |
+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
| 60 |
+
self.0.partial_cmp(&other.0)
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
impl From<f64> for Percentage {
|
| 65 |
+
fn from(v: f64) -> Self {
|
| 66 |
+
Self(Decimal::from(v))
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
impl From<Percentage> for f64 {
|
| 71 |
+
fn from(p: Percentage) -> Self {
|
| 72 |
+
p.0.into()
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
impl schemars::JsonSchema for Percentage {
|
| 77 |
+
fn schema_name() -> std::borrow::Cow<'static, str> {
|
| 78 |
+
Decimal::schema_name()
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
fn json_schema(r#gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
|
| 82 |
+
Decimal::json_schema(r#gen)
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
impl fake::Dummy<fake::Faker> for Percentage {
|
| 87 |
+
fn dummy_with_rng<R: fake::RngExt + ?Sized>(_: &fake::Faker, rng: &mut R) -> Self {
|
| 88 |
+
use fake::Fake;
|
| 89 |
+
Self(Decimal((0.0f64..=1.0f64).fake_with_rng::<f64, R>(rng)))
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
impl serde::Serialize for Percentage {
|
| 94 |
+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
| 95 |
+
self.0.serialize(serializer)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
impl<'de> serde::Deserialize<'de> for Percentage {
|
| 100 |
+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
| 101 |
+
let decimal = Decimal::deserialize(deserializer)?;
|
| 102 |
+
Self::new(decimal.value()).map_err(serde::de::Error::custom)
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
#[cfg(test)]
|
| 107 |
+
mod tests {
|
| 108 |
+
use pretty_assertions::assert_eq;
|
| 109 |
+
|
| 110 |
+
use super::*;
|
| 111 |
+
|
| 112 |
+
#[test]
|
| 113 |
+
fn test_percentage_valid_range() {
|
| 114 |
+
assert!(Percentage::new(0.0).is_ok());
|
| 115 |
+
assert!(Percentage::new(0.5).is_ok());
|
| 116 |
+
assert!(Percentage::new(1.0).is_ok());
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
#[test]
|
| 120 |
+
fn test_percentage_rejects_out_of_range() {
|
| 121 |
+
assert!(Percentage::new(-0.1).is_err());
|
| 122 |
+
assert!(Percentage::new(1.1).is_err());
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#[test]
|
| 126 |
+
fn test_percentage_serializes_to_2dp() {
|
| 127 |
+
#[derive(serde::Serialize)]
|
| 128 |
+
struct Fixture {
|
| 129 |
+
value: Percentage,
|
| 130 |
+
}
|
| 131 |
+
let fixture = Fixture { value: Percentage::new(0.2).unwrap() };
|
| 132 |
+
let actual = toml_edit::ser::to_string_pretty(&fixture).unwrap();
|
| 133 |
+
let expected = "value = 0.2\n";
|
| 134 |
+
assert_eq!(actual, expected);
|
| 135 |
+
}
|
| 136 |
+
}
|
crates/forge_config/src/reader.rs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::PathBuf;
|
| 2 |
+
use std::sync::LazyLock;
|
| 3 |
+
|
| 4 |
+
use config::ConfigBuilder;
|
| 5 |
+
use config::builder::DefaultState;
|
| 6 |
+
|
| 7 |
+
use crate::ForgeConfig;
|
| 8 |
+
use crate::legacy::LegacyConfig;
|
| 9 |
+
|
| 10 |
+
/// Loads all `.env` files found while walking up from the current working
|
| 11 |
+
/// directory to the root, with priority given to closer (lower) directories.
|
| 12 |
+
/// Executed at most once per process.
|
| 13 |
+
static LOAD_DOT_ENV: LazyLock<()> = LazyLock::new(|| {
|
| 14 |
+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
| 15 |
+
let mut paths = vec![];
|
| 16 |
+
let mut current = PathBuf::new();
|
| 17 |
+
|
| 18 |
+
for component in cwd.components() {
|
| 19 |
+
current.push(component);
|
| 20 |
+
paths.push(current.clone());
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
paths.reverse();
|
| 24 |
+
|
| 25 |
+
for path in paths {
|
| 26 |
+
let env_file = path.join(".env");
|
| 27 |
+
if env_file.is_file() {
|
| 28 |
+
dotenvy::from_path(&env_file).ok();
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
/// Caches base-path resolution for the process lifetime.
|
| 34 |
+
static BASE_PATH: LazyLock<PathBuf> = LazyLock::new(ConfigReader::resolve_base_path);
|
| 35 |
+
|
| 36 |
+
/// Merges [`ForgeConfig`] from layered sources using a builder pattern.
|
| 37 |
+
#[derive(Default)]
|
| 38 |
+
pub struct ConfigReader {
|
| 39 |
+
builder: ConfigBuilder<DefaultState>,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
impl ConfigReader {
|
| 43 |
+
/// Returns the path to the legacy JSON config file
|
| 44 |
+
/// (`~/.forge/.config.json`).
|
| 45 |
+
pub fn config_legacy_path() -> PathBuf {
|
| 46 |
+
Self::base_path().join(".config.json")
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/// Returns the path to the primary TOML config file
|
| 50 |
+
/// (`~/.forge/.forge.toml`).
|
| 51 |
+
pub fn config_path() -> PathBuf {
|
| 52 |
+
Self::base_path().join(".forge.toml")
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/// Returns the base directory for all Forge config files.
|
| 56 |
+
///
|
| 57 |
+
/// Resolution order:
|
| 58 |
+
/// 1. `FORGE_CONFIG` environment variable, if set.
|
| 59 |
+
/// 2. `~/forge` (legacy path), if that directory exists, so users who have
|
| 60 |
+
/// not yet run `forge config migrate` continue to read from their
|
| 61 |
+
/// existing directory without disruption.
|
| 62 |
+
/// 3. `~/.forge` as the default path.
|
| 63 |
+
pub fn base_path() -> PathBuf {
|
| 64 |
+
BASE_PATH.clone()
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
fn resolve_base_path() -> PathBuf {
|
| 68 |
+
if let Ok(path) = std::env::var("FORGE_CONFIG") {
|
| 69 |
+
return PathBuf::from(path);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
let base = dirs::home_dir().unwrap_or(PathBuf::from("."));
|
| 73 |
+
let path = base.join("forge");
|
| 74 |
+
|
| 75 |
+
// Prefer ~/forge (legacy) when it exists so existing users are not
|
| 76 |
+
// disrupted; fall back to ~/.forge as the default.
|
| 77 |
+
if path.exists() {
|
| 78 |
+
tracing::info!("Using legacy path");
|
| 79 |
+
return path;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
tracing::info!("Using new path");
|
| 83 |
+
base.join(".forge")
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/// Adds the provided TOML string as a config source without touching the
|
| 87 |
+
/// filesystem.
|
| 88 |
+
pub fn read_toml(mut self, contents: &str) -> Self {
|
| 89 |
+
self.builder = self
|
| 90 |
+
.builder
|
| 91 |
+
.add_source(config::File::from_str(contents, config::FileFormat::Toml));
|
| 92 |
+
|
| 93 |
+
self
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/// Adds the embedded default config (`../.forge.toml`) as a source.
|
| 97 |
+
pub fn read_defaults(self) -> Self {
|
| 98 |
+
let defaults = include_str!("../.forge.toml");
|
| 99 |
+
|
| 100 |
+
self.read_toml(defaults)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/// Adds `FORGE_`-prefixed environment variables as a config source.
|
| 104 |
+
pub fn read_env(mut self) -> Self {
|
| 105 |
+
self.builder = self.builder.add_source(
|
| 106 |
+
config::Environment::with_prefix("FORGE")
|
| 107 |
+
.prefix_separator("_")
|
| 108 |
+
.separator("__")
|
| 109 |
+
.try_parsing(true)
|
| 110 |
+
.list_separator(",")
|
| 111 |
+
.with_list_parse_key("retry.status_codes")
|
| 112 |
+
.with_list_parse_key("http.root_cert_paths"),
|
| 113 |
+
);
|
| 114 |
+
|
| 115 |
+
self
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/// Builds and deserializes all accumulated sources into a [`ForgeConfig`].
|
| 119 |
+
///
|
| 120 |
+
/// Triggers `.env` file loading (at most once per process) by walking up
|
| 121 |
+
/// the directory tree from the current working directory, with closer
|
| 122 |
+
/// directories taking priority.
|
| 123 |
+
///
|
| 124 |
+
/// # Errors
|
| 125 |
+
///
|
| 126 |
+
/// Returns an error if the configuration cannot be built or deserialized.
|
| 127 |
+
pub fn build(self) -> crate::Result<ForgeConfig> {
|
| 128 |
+
*LOAD_DOT_ENV;
|
| 129 |
+
let config = self.builder.build()?;
|
| 130 |
+
Ok(config.try_deserialize::<ForgeConfig>()?)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/// Adds `~/.forge/.forge.toml` as a config source, silently skipping if
|
| 134 |
+
/// absent.
|
| 135 |
+
pub fn read_global(mut self) -> Self {
|
| 136 |
+
let path = Self::config_path();
|
| 137 |
+
self.builder = self
|
| 138 |
+
.builder
|
| 139 |
+
.add_source(config::File::from(path).required(false));
|
| 140 |
+
self
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/// Reads `~/.forge/.config.json` (legacy format) and adds it as a source,
|
| 144 |
+
/// silently skipping errors.
|
| 145 |
+
pub fn read_legacy(self) -> Self {
|
| 146 |
+
let content = LegacyConfig::read(&Self::config_legacy_path());
|
| 147 |
+
if let Ok(content) = content {
|
| 148 |
+
self.read_toml(&content)
|
| 149 |
+
} else {
|
| 150 |
+
self
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[cfg(test)]
|
| 156 |
+
mod tests {
|
| 157 |
+
use std::sync::{Mutex, MutexGuard};
|
| 158 |
+
|
| 159 |
+
use pretty_assertions::assert_eq;
|
| 160 |
+
|
| 161 |
+
use super::*;
|
| 162 |
+
use crate::ModelConfig;
|
| 163 |
+
|
| 164 |
+
/// Serializes tests that mutate environment variables to prevent races.
|
| 165 |
+
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
| 166 |
+
|
| 167 |
+
/// Holds env vars set for a test's duration and removes them on drop, while
|
| 168 |
+
/// holding [`ENV_MUTEX`].
|
| 169 |
+
struct EnvGuard {
|
| 170 |
+
keys: Vec<&'static str>,
|
| 171 |
+
_lock: MutexGuard<'static, ()>,
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
impl EnvGuard {
|
| 175 |
+
/// Acquires [`ENV_MUTEX`], sets each `(key, value)` pair in the
|
| 176 |
+
/// environment, and removes each key in `remove` if present. All
|
| 177 |
+
/// set keys are cleaned up on drop.
|
| 178 |
+
#[must_use]
|
| 179 |
+
fn set(pairs: &[(&'static str, &str)]) -> Self {
|
| 180 |
+
Self::set_and_remove(pairs, &[])
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/// Like [`set`] but also removes the listed keys before the test runs.
|
| 184 |
+
#[must_use]
|
| 185 |
+
fn set_and_remove(pairs: &[(&'static str, &str)], remove: &[&'static str]) -> Self {
|
| 186 |
+
let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
| 187 |
+
let keys = pairs.iter().map(|(k, _)| *k).collect();
|
| 188 |
+
for key in remove {
|
| 189 |
+
unsafe { std::env::remove_var(key) };
|
| 190 |
+
}
|
| 191 |
+
for (key, value) in pairs {
|
| 192 |
+
unsafe { std::env::set_var(key, value) };
|
| 193 |
+
}
|
| 194 |
+
Self { keys, _lock: lock }
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
impl Drop for EnvGuard {
|
| 199 |
+
fn drop(&mut self) {
|
| 200 |
+
for key in &self.keys {
|
| 201 |
+
unsafe { std::env::remove_var(key) };
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
#[test]
|
| 207 |
+
fn test_base_path_uses_forge_config_env_var() {
|
| 208 |
+
let _guard = EnvGuard::set(&[("FORGE_CONFIG", "/custom/forge/dir")]);
|
| 209 |
+
let actual = ConfigReader::resolve_base_path();
|
| 210 |
+
let expected = PathBuf::from("/custom/forge/dir");
|
| 211 |
+
assert_eq!(actual, expected);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
#[test]
|
| 215 |
+
fn test_base_path_falls_back_to_home_dir_when_env_var_absent() {
|
| 216 |
+
// Hold the env mutex and ensure FORGE_CONFIG is absent so this test
|
| 217 |
+
// cannot race with test_base_path_uses_forge_config_env_var.
|
| 218 |
+
let _guard = EnvGuard::set_and_remove(&[], &["FORGE_CONFIG"]);
|
| 219 |
+
|
| 220 |
+
let actual = ConfigReader::resolve_base_path();
|
| 221 |
+
// Without FORGE_CONFIG set the path must be either "forge" (legacy,
|
| 222 |
+
// preferred when ~/forge exists) or ".forge" (default new path).
|
| 223 |
+
let name = actual.file_name().unwrap();
|
| 224 |
+
assert!(
|
| 225 |
+
name == "forge" || name == ".forge",
|
| 226 |
+
"Expected base_path to end with 'forge' or '.forge', got: {:?}",
|
| 227 |
+
name
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
#[test]
|
| 232 |
+
fn test_read_parses_without_error() {
|
| 233 |
+
let actual = ConfigReader::default().read_defaults().build();
|
| 234 |
+
assert!(actual.is_ok(), "read() failed: {:?}", actual.err());
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
#[test]
|
| 238 |
+
fn test_legacy_layer_does_not_overwrite_defaults() {
|
| 239 |
+
// Simulate what `read_legacy` does: serialize a ForgeConfig that only
|
| 240 |
+
// carries session/commit/suggest (all other fields are None) and layer
|
| 241 |
+
// it on top of the embedded defaults. The default values must survive.
|
| 242 |
+
let legacy = ForgeConfig {
|
| 243 |
+
session: Some(ModelConfig {
|
| 244 |
+
provider_id: "anthropic".to_string(),
|
| 245 |
+
model_id: "claude-3".to_string(),
|
| 246 |
+
}),
|
| 247 |
+
..Default::default()
|
| 248 |
+
};
|
| 249 |
+
let legacy_toml = toml_edit::ser::to_string_pretty(&legacy).unwrap();
|
| 250 |
+
|
| 251 |
+
let actual = ConfigReader::default()
|
| 252 |
+
// Read legacy first and then defaults
|
| 253 |
+
.read_toml(&legacy_toml)
|
| 254 |
+
.read_defaults()
|
| 255 |
+
.build()
|
| 256 |
+
.unwrap();
|
| 257 |
+
|
| 258 |
+
// Session should come from the legacy layer
|
| 259 |
+
assert_eq!(
|
| 260 |
+
actual.session,
|
| 261 |
+
Some(ModelConfig {
|
| 262 |
+
provider_id: "anthropic".to_string(),
|
| 263 |
+
model_id: "claude-3".to_string(),
|
| 264 |
+
})
|
| 265 |
+
);
|
| 266 |
+
|
| 267 |
+
// Default values from .forge.toml must be retained, not reset to zero
|
| 268 |
+
assert_eq!(actual.max_parallel_file_reads, 64);
|
| 269 |
+
assert_eq!(actual.max_read_lines, 2000);
|
| 270 |
+
assert_eq!(actual.tool_timeout_secs, 300);
|
| 271 |
+
assert_eq!(actual.max_search_lines, 1000);
|
| 272 |
+
assert_eq!(actual.tool_supported, true);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
#[test]
|
| 276 |
+
fn test_read_session_from_env_vars() {
|
| 277 |
+
let _guard = EnvGuard::set(&[
|
| 278 |
+
("FORGE_SESSION__PROVIDER_ID", "fake-provider"),
|
| 279 |
+
("FORGE_SESSION__MODEL_ID", "fake-model"),
|
| 280 |
+
]);
|
| 281 |
+
|
| 282 |
+
let actual = ConfigReader::default()
|
| 283 |
+
.read_defaults()
|
| 284 |
+
.read_env()
|
| 285 |
+
.build()
|
| 286 |
+
.unwrap();
|
| 287 |
+
|
| 288 |
+
let expected = Some(ModelConfig {
|
| 289 |
+
provider_id: "fake-provider".to_string(),
|
| 290 |
+
model_id: "fake-model".to_string(),
|
| 291 |
+
});
|
| 292 |
+
assert_eq!(actual.session, expected);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
#[test]
|
| 296 |
+
fn test_use_forge_committer_defaults_to_true() {
|
| 297 |
+
let actual = ConfigReader::default().read_defaults().build().unwrap();
|
| 298 |
+
|
| 299 |
+
assert_eq!(actual.use_forge_committer, true);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
#[test]
|
| 303 |
+
fn test_use_forge_committer_can_be_disabled() {
|
| 304 |
+
let toml = "use_forge_committer = false\n";
|
| 305 |
+
|
| 306 |
+
let actual = ConfigReader::default()
|
| 307 |
+
.read_defaults()
|
| 308 |
+
.read_toml(toml)
|
| 309 |
+
.build()
|
| 310 |
+
.unwrap();
|
| 311 |
+
|
| 312 |
+
assert_eq!(actual.use_forge_committer, false);
|
| 313 |
+
}
|
| 314 |
+
}
|
crates/forge_config/src/reasoning.rs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use schemars::JsonSchema;
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
use strum_macros::Display as StrumDisplay;
|
| 5 |
+
|
| 6 |
+
/// Controls the reasoning behaviour of a model, including effort level, token
|
| 7 |
+
/// budget, and visibility of the thinking process.
|
| 8 |
+
#[derive(
|
| 9 |
+
Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy, Setters,
|
| 10 |
+
)]
|
| 11 |
+
#[serde(rename_all = "snake_case")]
|
| 12 |
+
#[setters(strip_option)]
|
| 13 |
+
pub struct ReasoningConfig {
|
| 14 |
+
/// Controls the effort level of the model's reasoning.
|
| 15 |
+
/// Supported by openrouter and forge provider.
|
| 16 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 17 |
+
pub effort: Option<Effort>,
|
| 18 |
+
|
| 19 |
+
/// Controls how many tokens the model can spend thinking.
|
| 20 |
+
/// Should be greater than 1024 but less than the overall max_tokens.
|
| 21 |
+
/// Supported by openrouter, anthropic, and forge provider.
|
| 22 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 23 |
+
pub max_tokens: Option<usize>,
|
| 24 |
+
|
| 25 |
+
/// When true, the model thinks deeply but the reasoning is hidden from the
|
| 26 |
+
/// caller. Supported by openrouter and forge provider.
|
| 27 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 28 |
+
pub exclude: Option<bool>,
|
| 29 |
+
|
| 30 |
+
/// Enables reasoning at the "medium" effort level with no exclusions.
|
| 31 |
+
/// Supported by openrouter, anthropic, and forge provider.
|
| 32 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 33 |
+
pub enabled: Option<bool>,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/// Effort level for model reasoning.
|
| 37 |
+
#[derive(
|
| 38 |
+
Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy, StrumDisplay,
|
| 39 |
+
)]
|
| 40 |
+
#[serde(rename_all = "lowercase")]
|
| 41 |
+
#[strum(serialize_all = "lowercase")]
|
| 42 |
+
pub enum Effort {
|
| 43 |
+
/// No reasoning; skips the thinking step entirely.
|
| 44 |
+
None,
|
| 45 |
+
/// Minimal reasoning; fastest and cheapest.
|
| 46 |
+
Minimal,
|
| 47 |
+
/// Low reasoning effort.
|
| 48 |
+
Low,
|
| 49 |
+
/// Medium reasoning effort; the default for most providers.
|
| 50 |
+
Medium,
|
| 51 |
+
/// High reasoning effort.
|
| 52 |
+
High,
|
| 53 |
+
/// Extra-high reasoning effort (OpenAI / OpenRouter).
|
| 54 |
+
XHigh,
|
| 55 |
+
/// Maximum reasoning effort; only available on select Anthropic models.
|
| 56 |
+
Max,
|
| 57 |
+
}
|
crates/forge_config/src/retry.rs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use derive_setters::Setters;
|
| 2 |
+
use schemars::JsonSchema;
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
|
| 5 |
+
/// Configuration for retry mechanism.
|
| 6 |
+
#[derive(
|
| 7 |
+
Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy, Setters,
|
| 8 |
+
)]
|
| 9 |
+
#[serde(rename_all = "snake_case")]
|
| 10 |
+
#[setters(into)]
|
| 11 |
+
pub struct RetryConfig {
|
| 12 |
+
/// Initial backoff delay in milliseconds for retry operations
|
| 13 |
+
pub initial_backoff_ms: u64,
|
| 14 |
+
/// Minimum delay in milliseconds between retry attempts
|
| 15 |
+
pub min_delay_ms: u64,
|
| 16 |
+
/// Backoff multiplication factor for each retry attempt
|
| 17 |
+
pub backoff_factor: u64,
|
| 18 |
+
/// Maximum number of retry attempts
|
| 19 |
+
pub max_attempts: usize,
|
| 20 |
+
/// HTTP status codes that should trigger retries
|
| 21 |
+
pub status_codes: Vec<u16>,
|
| 22 |
+
/// Maximum delay between retries in seconds
|
| 23 |
+
pub max_delay_secs: Option<u64>,
|
| 24 |
+
/// Whether to suppress retry error logging and events
|
| 25 |
+
pub suppress_errors: bool,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[cfg(test)]
|
| 29 |
+
mod tests {
|
| 30 |
+
use pretty_assertions::assert_eq;
|
| 31 |
+
|
| 32 |
+
use super::*;
|
| 33 |
+
|
| 34 |
+
#[test]
|
| 35 |
+
fn test_retry_config_fields() {
|
| 36 |
+
let config = RetryConfig {
|
| 37 |
+
initial_backoff_ms: 200,
|
| 38 |
+
min_delay_ms: 1000,
|
| 39 |
+
backoff_factor: 2,
|
| 40 |
+
max_attempts: 8,
|
| 41 |
+
status_codes: vec![429, 500, 502, 503, 504, 408, 522, 524, 520, 529],
|
| 42 |
+
max_delay_secs: None,
|
| 43 |
+
suppress_errors: false,
|
| 44 |
+
};
|
| 45 |
+
assert_eq!(config.initial_backoff_ms, 200);
|
| 46 |
+
assert_eq!(config.suppress_errors, false);
|
| 47 |
+
}
|
| 48 |
+
}
|
crates/forge_config/src/writer.rs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::Path;
|
| 2 |
+
|
| 3 |
+
use crate::ForgeConfig;
|
| 4 |
+
|
| 5 |
+
/// Writes a [`ForgeConfig`] to the user configuration file on disk.
|
| 6 |
+
pub struct ConfigWriter {
|
| 7 |
+
config: ForgeConfig,
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
impl ConfigWriter {
|
| 11 |
+
/// Creates a new `ConfigWriter` for the given configuration.
|
| 12 |
+
pub fn new(config: ForgeConfig) -> Self {
|
| 13 |
+
Self { config }
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/// Serializes and writes the configuration to `path`, creating all parent
|
| 17 |
+
/// directories recursively if they do not already exist.
|
| 18 |
+
///
|
| 19 |
+
/// The output includes a leading `$schema` key pointing to the Forge
|
| 20 |
+
/// configuration JSON schema, which enables editor validation and
|
| 21 |
+
/// auto-complete.
|
| 22 |
+
///
|
| 23 |
+
/// # Errors
|
| 24 |
+
///
|
| 25 |
+
/// Returns an error if the configuration cannot be serialized or the file
|
| 26 |
+
/// cannot be written.
|
| 27 |
+
pub fn write(&self, path: &Path) -> crate::Result<()> {
|
| 28 |
+
if let Some(parent) = path.parent() {
|
| 29 |
+
std::fs::create_dir_all(parent)?;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let config_toml = toml_edit::ser::to_string_pretty(&self.config)?;
|
| 33 |
+
let contents =
|
| 34 |
+
format!("\"$schema\" = \"https://forgecode.dev/schema.json\"\n\n{config_toml}");
|
| 35 |
+
|
| 36 |
+
std::fs::write(path, contents)?;
|
| 37 |
+
|
| 38 |
+
Ok(())
|
| 39 |
+
}
|
| 40 |
+
}
|
crates/forge_config/tests/schema.rs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::Path;
|
| 2 |
+
|
| 3 |
+
use forge_config::ForgeConfig;
|
| 4 |
+
use pretty_assertions::assert_eq;
|
| 5 |
+
|
| 6 |
+
#[tokio::test]
|
| 7 |
+
async fn generate_workflow_schema() -> anyhow::Result<()> {
|
| 8 |
+
let schema = schemars::schema_for!(ForgeConfig);
|
| 9 |
+
let generated_schema = serde_json::to_string_pretty(&schema)?;
|
| 10 |
+
|
| 11 |
+
// Use the crate root directory for the schema file
|
| 12 |
+
let crate_root = env!("CARGO_MANIFEST_DIR");
|
| 13 |
+
let schema_path = Path::new(crate_root).join("../../forge.schema.json");
|
| 14 |
+
|
| 15 |
+
if is_ci::uncached() {
|
| 16 |
+
// On CI: validate that the generated schema matches the committed file
|
| 17 |
+
let existing_schema = tokio::fs::read_to_string(&schema_path).await?;
|
| 18 |
+
assert_eq!(
|
| 19 |
+
generated_schema.trim(),
|
| 20 |
+
existing_schema.trim(),
|
| 21 |
+
"Generated workflow schema does not match the committed schema file. \
|
| 22 |
+
Please run the test locally to update the schema file."
|
| 23 |
+
);
|
| 24 |
+
} else {
|
| 25 |
+
// Locally: generate and write the schema file
|
| 26 |
+
tokio::fs::write(&schema_path, generated_schema).await?;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
Ok(())
|
| 30 |
+
}
|
crates/forge_embed/src/lib.rs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use handlebars::Handlebars;
|
| 2 |
+
use include_dir::{Dir, DirEntry, File};
|
| 3 |
+
|
| 4 |
+
/// Returns an iterator over all files embedded in `dir`, recursively
|
| 5 |
+
/// descending into subdirectories.
|
| 6 |
+
pub fn files(dir: &'static Dir<'static>) -> impl Iterator<Item = &'static File<'static>> {
|
| 7 |
+
dir.entries().iter().flat_map(walk_entry)
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
fn walk_entry(
|
| 11 |
+
entry: &'static DirEntry<'static>,
|
| 12 |
+
) -> Box<dyn Iterator<Item = &'static File<'static>>> {
|
| 13 |
+
match entry {
|
| 14 |
+
DirEntry::File(f) => Box::new(std::iter::once(f)),
|
| 15 |
+
DirEntry::Dir(d) => Box::new(d.entries().iter().flat_map(walk_entry)),
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/// Registers all files in `dir` (recursively) as Handlebars templates.
|
| 20 |
+
///
|
| 21 |
+
/// Template names match the relative file path as returned by
|
| 22 |
+
/// [`File::path`] (e.g. `forge-system-prompt.md`). Panics if any file path
|
| 23 |
+
/// is not valid UTF-8, if any file content is not valid UTF-8, or if template
|
| 24 |
+
/// parsing fails.
|
| 25 |
+
pub fn register_templates(hb: &mut Handlebars<'_>, dir: &'static Dir<'static>) {
|
| 26 |
+
for file in files(dir) {
|
| 27 |
+
let name = file.path().to_str().unwrap_or_else(|| {
|
| 28 |
+
panic!(
|
| 29 |
+
"embedded template path '{:?}' is not valid UTF-8",
|
| 30 |
+
file.path()
|
| 31 |
+
)
|
| 32 |
+
});
|
| 33 |
+
let content = file
|
| 34 |
+
.contents_utf8()
|
| 35 |
+
.unwrap_or_else(|| panic!("embedded template '{}' is not valid UTF-8", name));
|
| 36 |
+
hb.register_template_string(name, content)
|
| 37 |
+
.unwrap_or_else(|e| panic!("failed to register template '{}': {}", name, e));
|
| 38 |
+
}
|
| 39 |
+
}
|
crates/forge_eventsource/src/error.rs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use core::fmt;
|
| 2 |
+
use std::string::FromUtf8Error;
|
| 3 |
+
|
| 4 |
+
use forge_eventsource_stream::EventStreamError;
|
| 5 |
+
use nom::error::Error as NomError;
|
| 6 |
+
#[cfg(doc)]
|
| 7 |
+
use reqwest::RequestBuilder;
|
| 8 |
+
use reqwest::header::HeaderValue;
|
| 9 |
+
use reqwest::{Error as ReqwestError, Response, StatusCode};
|
| 10 |
+
|
| 11 |
+
/// Error raised when a [`RequestBuilder`] cannot be cloned. See
|
| 12 |
+
/// [`RequestBuilder::try_clone`] for more information
|
| 13 |
+
#[derive(Debug, Clone, Copy)]
|
| 14 |
+
pub struct CannotCloneRequestError;
|
| 15 |
+
|
| 16 |
+
impl fmt::Display for CannotCloneRequestError {
|
| 17 |
+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
| 18 |
+
f.write_str("expected a cloneable request")
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
impl std::error::Error for CannotCloneRequestError {}
|
| 23 |
+
|
| 24 |
+
/// Error raised by the EventSource stream fetching and parsing
|
| 25 |
+
#[derive(Debug, Error)]
|
| 26 |
+
pub enum Error {
|
| 27 |
+
/// Source stream is not valid UTF8
|
| 28 |
+
#[error(transparent)]
|
| 29 |
+
Utf8(FromUtf8Error),
|
| 30 |
+
/// Source stream is not a valid EventStream
|
| 31 |
+
#[error(transparent)]
|
| 32 |
+
Parser(NomError<String>),
|
| 33 |
+
/// The HTTP Request could not be completed
|
| 34 |
+
#[error(transparent)]
|
| 35 |
+
Transport(ReqwestError),
|
| 36 |
+
/// The `Content-Type` returned by the server is invalid
|
| 37 |
+
#[error("Invalid header value: {0:?}")]
|
| 38 |
+
InvalidContentType(HeaderValue, Box<Response>),
|
| 39 |
+
/// The status code returned by the server is invalid
|
| 40 |
+
#[error("Invalid status code: {0}")]
|
| 41 |
+
InvalidStatusCode(StatusCode, Box<Response>),
|
| 42 |
+
/// The `Last-Event-ID` cannot be formed into a Header to be submitted to
|
| 43 |
+
/// the server
|
| 44 |
+
#[error("Invalid `Last-Event-ID`: {0}")]
|
| 45 |
+
InvalidLastEventId(String),
|
| 46 |
+
/// The stream ended
|
| 47 |
+
#[error("Stream ended")]
|
| 48 |
+
StreamEnded,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
impl From<EventStreamError<ReqwestError>> for Error {
|
| 52 |
+
fn from(err: EventStreamError<ReqwestError>) -> Self {
|
| 53 |
+
match err {
|
| 54 |
+
EventStreamError::Utf8(err) => Self::Utf8(err),
|
| 55 |
+
EventStreamError::Parser(err) => Self::Parser(err),
|
| 56 |
+
EventStreamError::Transport(err) => Self::Transport(err),
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
crates/forge_eventsource/src/event_source.rs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use core::pin::Pin;
|
| 2 |
+
use std::time::Duration;
|
| 3 |
+
|
| 4 |
+
use forge_eventsource_stream::Eventsource;
|
| 5 |
+
pub use forge_eventsource_stream::{Event as MessageEvent, EventStreamError};
|
| 6 |
+
#[cfg(not(target_arch = "wasm32"))]
|
| 7 |
+
use futures_core::future::BoxFuture;
|
| 8 |
+
use futures_core::future::Future;
|
| 9 |
+
#[cfg(target_arch = "wasm32")]
|
| 10 |
+
use futures_core::future::LocalBoxFuture;
|
| 11 |
+
#[cfg(not(target_arch = "wasm32"))]
|
| 12 |
+
use futures_core::stream::BoxStream;
|
| 13 |
+
#[cfg(target_arch = "wasm32")]
|
| 14 |
+
use futures_core::stream::LocalBoxStream;
|
| 15 |
+
use futures_core::stream::Stream;
|
| 16 |
+
use futures_core::task::{Context, Poll};
|
| 17 |
+
use futures_timer::Delay;
|
| 18 |
+
use pin_project_lite::pin_project;
|
| 19 |
+
use reqwest::header::{HeaderName, HeaderValue};
|
| 20 |
+
use reqwest::{Error as ReqwestError, IntoUrl, RequestBuilder, Response, StatusCode};
|
| 21 |
+
|
| 22 |
+
use crate::error::{CannotCloneRequestError, Error};
|
| 23 |
+
use crate::retry::{DEFAULT_RETRY, RetryPolicy};
|
| 24 |
+
|
| 25 |
+
#[cfg(not(target_arch = "wasm32"))]
|
| 26 |
+
type ResponseFuture = BoxFuture<'static, Result<Response, ReqwestError>>;
|
| 27 |
+
#[cfg(target_arch = "wasm32")]
|
| 28 |
+
type ResponseFuture = LocalBoxFuture<'static, Result<Response, ReqwestError>>;
|
| 29 |
+
|
| 30 |
+
#[cfg(not(target_arch = "wasm32"))]
|
| 31 |
+
type EventStream = BoxStream<'static, Result<MessageEvent, EventStreamError<ReqwestError>>>;
|
| 32 |
+
#[cfg(target_arch = "wasm32")]
|
| 33 |
+
type EventStream = LocalBoxStream<'static, Result<MessageEvent, EventStreamError<ReqwestError>>>;
|
| 34 |
+
|
| 35 |
+
type BoxedRetry = Box<dyn RetryPolicy + Send + Unpin + 'static>;
|
| 36 |
+
|
| 37 |
+
/// The ready state of an [`EventSource`]
|
| 38 |
+
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
|
| 39 |
+
#[repr(u8)]
|
| 40 |
+
pub enum ReadyState {
|
| 41 |
+
/// The EventSource is waiting on a response from the endpoint
|
| 42 |
+
Connecting = 0,
|
| 43 |
+
/// The EventSource is connected
|
| 44 |
+
Open = 1,
|
| 45 |
+
/// The EventSource is closed and no longer emitting Events
|
| 46 |
+
Closed = 2,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
pin_project! {
|
| 50 |
+
/// Provides the [`Stream`] implementation for the [`Event`] items. This wraps the
|
| 51 |
+
/// [`RequestBuilder`] and retries requests when they fail.
|
| 52 |
+
#[project = EventSourceProjection]
|
| 53 |
+
pub struct EventSource {
|
| 54 |
+
builder: RequestBuilder,
|
| 55 |
+
#[pin]
|
| 56 |
+
next_response: Option<ResponseFuture>,
|
| 57 |
+
#[pin]
|
| 58 |
+
cur_stream: Option<EventStream>,
|
| 59 |
+
#[pin]
|
| 60 |
+
delay: Option<Delay>,
|
| 61 |
+
is_closed: bool,
|
| 62 |
+
retry_policy: BoxedRetry,
|
| 63 |
+
last_event_id: String,
|
| 64 |
+
last_retry: Option<(usize, Duration)>
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
impl EventSource {
|
| 69 |
+
/// Wrap a [`RequestBuilder`]
|
| 70 |
+
pub fn new(builder: RequestBuilder) -> Result<Self, CannotCloneRequestError> {
|
| 71 |
+
let builder = builder.header(
|
| 72 |
+
reqwest::header::ACCEPT,
|
| 73 |
+
HeaderValue::from_static("text/event-stream"),
|
| 74 |
+
);
|
| 75 |
+
let res_future = Box::pin(builder.try_clone().ok_or(CannotCloneRequestError)?.send());
|
| 76 |
+
Ok(Self {
|
| 77 |
+
builder,
|
| 78 |
+
next_response: Some(res_future),
|
| 79 |
+
cur_stream: None,
|
| 80 |
+
delay: None,
|
| 81 |
+
is_closed: false,
|
| 82 |
+
retry_policy: Box::new(DEFAULT_RETRY),
|
| 83 |
+
last_event_id: String::new(),
|
| 84 |
+
last_retry: None,
|
| 85 |
+
})
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/// Create a simple EventSource based on a GET request
|
| 89 |
+
pub fn get<T: IntoUrl>(url: T) -> Self {
|
| 90 |
+
Self::new(reqwest::Client::new().get(url)).unwrap()
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/// Close the EventSource stream and stop trying to reconnect
|
| 94 |
+
pub fn close(&mut self) {
|
| 95 |
+
self.is_closed = true;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/// Set the retry policy
|
| 99 |
+
pub fn set_retry_policy(&mut self, policy: BoxedRetry) {
|
| 100 |
+
self.retry_policy = policy
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/// Get the last event id
|
| 104 |
+
pub fn last_event_id(&self) -> &str {
|
| 105 |
+
&self.last_event_id
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/// Get the current ready state
|
| 109 |
+
pub fn ready_state(&self) -> ReadyState {
|
| 110 |
+
if self.is_closed {
|
| 111 |
+
ReadyState::Closed
|
| 112 |
+
} else if self.delay.is_some() || self.next_response.is_some() {
|
| 113 |
+
ReadyState::Connecting
|
| 114 |
+
} else {
|
| 115 |
+
ReadyState::Open
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
fn check_response(response: Response) -> Result<Response, Error> {
|
| 121 |
+
match response.status() {
|
| 122 |
+
StatusCode::OK => {}
|
| 123 |
+
status => {
|
| 124 |
+
return Err(Error::InvalidStatusCode(status, Box::new(response)));
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
let content_type =
|
| 128 |
+
if let Some(content_type) = response.headers().get(&reqwest::header::CONTENT_TYPE) {
|
| 129 |
+
content_type
|
| 130 |
+
} else {
|
| 131 |
+
return Err(Error::InvalidContentType(
|
| 132 |
+
HeaderValue::from_static(""),
|
| 133 |
+
Box::new(response),
|
| 134 |
+
));
|
| 135 |
+
};
|
| 136 |
+
if content_type
|
| 137 |
+
.to_str()
|
| 138 |
+
.map_err(|_| ())
|
| 139 |
+
.and_then(|s| s.parse::<mime::Mime>().map_err(|_| ()))
|
| 140 |
+
.map(|mime_type| {
|
| 141 |
+
matches!(
|
| 142 |
+
(mime_type.type_(), mime_type.subtype()),
|
| 143 |
+
(mime::TEXT, mime::EVENT_STREAM)
|
| 144 |
+
)
|
| 145 |
+
})
|
| 146 |
+
.unwrap_or(false)
|
| 147 |
+
{
|
| 148 |
+
Ok(response)
|
| 149 |
+
} else {
|
| 150 |
+
Err(Error::InvalidContentType(
|
| 151 |
+
content_type.clone(),
|
| 152 |
+
Box::new(response),
|
| 153 |
+
))
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
impl<'a> EventSourceProjection<'a> {
|
| 158 |
+
fn clear_fetch(&mut self) {
|
| 159 |
+
self.next_response.take();
|
| 160 |
+
self.cur_stream.take();
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
fn retry_fetch(&mut self) -> Result<(), Error> {
|
| 164 |
+
self.cur_stream.take();
|
| 165 |
+
let req = self.builder.try_clone().unwrap().header(
|
| 166 |
+
HeaderName::from_static("last-event-id"),
|
| 167 |
+
HeaderValue::from_str(self.last_event_id)
|
| 168 |
+
.map_err(|_| Error::InvalidLastEventId(self.last_event_id.clone()))?,
|
| 169 |
+
);
|
| 170 |
+
let res_future = Box::pin(req.send());
|
| 171 |
+
self.next_response.replace(res_future);
|
| 172 |
+
Ok(())
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
fn handle_response(&mut self, res: Response) {
|
| 176 |
+
self.last_retry.take();
|
| 177 |
+
let mut stream = res.bytes_stream().eventsource();
|
| 178 |
+
stream.set_last_event_id(self.last_event_id.clone());
|
| 179 |
+
self.cur_stream.replace(Box::pin(stream));
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
fn handle_event(&mut self, event: &MessageEvent) {
|
| 183 |
+
*self.last_event_id = event.id.clone();
|
| 184 |
+
if let Some(duration) = event.retry {
|
| 185 |
+
self.retry_policy.set_reconnection_time(duration)
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
fn handle_error(&mut self, error: &Error) {
|
| 190 |
+
self.clear_fetch();
|
| 191 |
+
if let Some(retry_delay) = self.retry_policy.retry(error, *self.last_retry) {
|
| 192 |
+
let retry_num = self.last_retry.map(|retry| retry.0).unwrap_or(1);
|
| 193 |
+
*self.last_retry = Some((retry_num, retry_delay));
|
| 194 |
+
self.delay.replace(Delay::new(retry_delay));
|
| 195 |
+
} else {
|
| 196 |
+
*self.is_closed = true;
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/// Events created by the [`EventSource`]
|
| 202 |
+
#[derive(Debug, Clone, Eq, PartialEq)]
|
| 203 |
+
pub enum Event {
|
| 204 |
+
/// The event fired when the connection is opened
|
| 205 |
+
Open,
|
| 206 |
+
/// The event fired when a [`MessageEvent`] is received
|
| 207 |
+
Message(MessageEvent),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
impl From<MessageEvent> for Event {
|
| 211 |
+
fn from(event: MessageEvent) -> Self {
|
| 212 |
+
Event::Message(event)
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
impl Stream for EventSource {
|
| 217 |
+
type Item = Result<Event, Error>;
|
| 218 |
+
|
| 219 |
+
fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
|
| 220 |
+
let mut this = self.project();
|
| 221 |
+
|
| 222 |
+
if *this.is_closed {
|
| 223 |
+
return Poll::Ready(None);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if let Some(delay) = this.delay.as_mut().as_pin_mut() {
|
| 227 |
+
match delay.poll(cx) {
|
| 228 |
+
Poll::Ready(_) => {
|
| 229 |
+
this.delay.take();
|
| 230 |
+
if let Err(err) = this.retry_fetch() {
|
| 231 |
+
*this.is_closed = true;
|
| 232 |
+
return Poll::Ready(Some(Err(err)));
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
Poll::Pending => return Poll::Pending,
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
if let Some(response_future) = this.next_response.as_mut().as_pin_mut() {
|
| 240 |
+
match response_future.poll(cx) {
|
| 241 |
+
Poll::Ready(Ok(res)) => {
|
| 242 |
+
this.clear_fetch();
|
| 243 |
+
match check_response(res) {
|
| 244 |
+
Ok(res) => {
|
| 245 |
+
this.handle_response(res);
|
| 246 |
+
return Poll::Ready(Some(Ok(Event::Open)));
|
| 247 |
+
}
|
| 248 |
+
Err(err) => {
|
| 249 |
+
*this.is_closed = true;
|
| 250 |
+
return Poll::Ready(Some(Err(err)));
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
Poll::Ready(Err(err)) => {
|
| 255 |
+
let err = Error::Transport(err);
|
| 256 |
+
this.handle_error(&err);
|
| 257 |
+
return Poll::Ready(Some(Err(err)));
|
| 258 |
+
}
|
| 259 |
+
Poll::Pending => {
|
| 260 |
+
return Poll::Pending;
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
match this
|
| 266 |
+
.cur_stream
|
| 267 |
+
.as_mut()
|
| 268 |
+
.as_pin_mut()
|
| 269 |
+
.unwrap()
|
| 270 |
+
.as_mut()
|
| 271 |
+
.poll_next(cx)
|
| 272 |
+
{
|
| 273 |
+
Poll::Ready(Some(Err(err))) => {
|
| 274 |
+
let err = err.into();
|
| 275 |
+
this.handle_error(&err);
|
| 276 |
+
Poll::Ready(Some(Err(err)))
|
| 277 |
+
}
|
| 278 |
+
Poll::Ready(Some(Ok(event))) => {
|
| 279 |
+
this.handle_event(&event);
|
| 280 |
+
Poll::Ready(Some(Ok(event.into())))
|
| 281 |
+
}
|
| 282 |
+
Poll::Ready(None) => {
|
| 283 |
+
let err = Error::StreamEnded;
|
| 284 |
+
this.handle_error(&err);
|
| 285 |
+
Poll::Ready(Some(Err(err)))
|
| 286 |
+
}
|
| 287 |
+
Poll::Pending => Poll::Pending,
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
}
|
crates/forge_eventsource/src/lib.rs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Provides a simple wrapper for [`reqwest`] to provide an Event Source
|
| 2 |
+
//! implementation. You can learn more about Server Sent Events (SSE) take a
|
| 3 |
+
//! look at [the MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
|
| 4 |
+
//! This crate uses [`forge_eventsource_stream`] to wrap the underlying Bytes
|
| 5 |
+
//! stream, and retries failed requests.
|
| 6 |
+
//!
|
| 7 |
+
//! # Example
|
| 8 |
+
//!
|
| 9 |
+
//! ```ignore
|
| 10 |
+
//! let mut es = EventSource::get("http://localhost:8000/events");
|
| 11 |
+
//! while let Some(event) = es.next().await {
|
| 12 |
+
//! match event {
|
| 13 |
+
//! Ok(Event::Open) => println!("Connection Open!"),
|
| 14 |
+
//! Ok(Event::Message(message)) => println!("Message: {:#?}", message),
|
| 15 |
+
//! Err(err) => {
|
| 16 |
+
//! println!("Error: {}", err);
|
| 17 |
+
//! es.close();
|
| 18 |
+
//! }
|
| 19 |
+
//! }
|
| 20 |
+
//! }
|
| 21 |
+
//! ```
|
| 22 |
+
|
| 23 |
+
#[macro_use]
|
| 24 |
+
extern crate thiserror;
|
| 25 |
+
|
| 26 |
+
mod error;
|
| 27 |
+
mod event_source;
|
| 28 |
+
mod reqwest_ext;
|
| 29 |
+
pub mod retry;
|
| 30 |
+
|
| 31 |
+
pub use error::{CannotCloneRequestError, Error};
|
| 32 |
+
pub use event_source::{Event, EventSource, ReadyState};
|
| 33 |
+
pub use reqwest_ext::RequestBuilderExt;
|
crates/forge_eventsource/src/reqwest_ext.rs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use reqwest::RequestBuilder;
|
| 2 |
+
|
| 3 |
+
use crate::error::CannotCloneRequestError;
|
| 4 |
+
use crate::event_source::EventSource;
|
| 5 |
+
|
| 6 |
+
/// Provides an easy interface to build an [`EventSource`] from a
|
| 7 |
+
/// [`RequestBuilder`]
|
| 8 |
+
pub trait RequestBuilderExt {
|
| 9 |
+
/// Create a new [`EventSource`] from a [`RequestBuilder`]
|
| 10 |
+
fn eventsource(self) -> Result<EventSource, CannotCloneRequestError>;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
impl RequestBuilderExt for RequestBuilder {
|
| 14 |
+
fn eventsource(self) -> Result<EventSource, CannotCloneRequestError> {
|
| 15 |
+
EventSource::new(self)
|
| 16 |
+
}
|
| 17 |
+
}
|
crates/forge_eventsource/src/retry.rs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Helpers to handle connection delays when receiving errors
|
| 2 |
+
|
| 3 |
+
use std::time::Duration;
|
| 4 |
+
|
| 5 |
+
use crate::error::Error;
|
| 6 |
+
#[cfg(doc)]
|
| 7 |
+
use crate::event_source::{Event, EventSource};
|
| 8 |
+
|
| 9 |
+
/// Describes how an [`EventSource`] should retry on receiving an [`enum@Error`]
|
| 10 |
+
pub trait RetryPolicy {
|
| 11 |
+
/// Submit a new retry delay based on the [`enum@Error`], last retry number
|
| 12 |
+
/// and duration, if available. A policy may also return `None` if it
|
| 13 |
+
/// does not want to retry
|
| 14 |
+
fn retry(&self, error: &Error, last_retry: Option<(usize, Duration)>) -> Option<Duration>;
|
| 15 |
+
|
| 16 |
+
/// Set a new reconnection time if received from an [`Event`]
|
| 17 |
+
fn set_reconnection_time(&mut self, duration: Duration);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/// A [`RetryPolicy`] which backs off exponentially
|
| 21 |
+
#[derive(Debug, Clone)]
|
| 22 |
+
pub struct ExponentialBackoff {
|
| 23 |
+
/// The start of the backoff
|
| 24 |
+
pub start: Duration,
|
| 25 |
+
/// The factor of which to backoff by
|
| 26 |
+
pub factor: f64,
|
| 27 |
+
/// The maximum duration to delay
|
| 28 |
+
pub max_duration: Option<Duration>,
|
| 29 |
+
/// The maximum number of retries before giving up
|
| 30 |
+
pub max_retries: Option<usize>,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
impl ExponentialBackoff {
|
| 34 |
+
/// Create a new exponential backoff retry policy
|
| 35 |
+
pub const fn new(
|
| 36 |
+
start: Duration,
|
| 37 |
+
factor: f64,
|
| 38 |
+
max_duration: Option<Duration>,
|
| 39 |
+
max_retries: Option<usize>,
|
| 40 |
+
) -> Self {
|
| 41 |
+
Self { start, factor, max_duration, max_retries }
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
impl RetryPolicy for ExponentialBackoff {
|
| 46 |
+
fn retry(&self, _error: &Error, last_retry: Option<(usize, Duration)>) -> Option<Duration> {
|
| 47 |
+
if let Some((retry_num, last_duration)) = last_retry {
|
| 48 |
+
if self.max_retries.is_none() || retry_num < self.max_retries.unwrap() {
|
| 49 |
+
let duration = last_duration.mul_f64(self.factor);
|
| 50 |
+
if let Some(max_duration) = self.max_duration {
|
| 51 |
+
Some(duration.min(max_duration))
|
| 52 |
+
} else {
|
| 53 |
+
Some(duration)
|
| 54 |
+
}
|
| 55 |
+
} else {
|
| 56 |
+
None
|
| 57 |
+
}
|
| 58 |
+
} else {
|
| 59 |
+
Some(self.start)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
fn set_reconnection_time(&mut self, duration: Duration) {
|
| 63 |
+
self.start = duration;
|
| 64 |
+
if let Some(max_duration) = self.max_duration {
|
| 65 |
+
self.max_duration = Some(max_duration.max(duration))
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/// A [`RetryPolicy`] which always emits the same delay
|
| 71 |
+
#[derive(Debug, Clone)]
|
| 72 |
+
pub struct Constant {
|
| 73 |
+
/// The delay to return
|
| 74 |
+
pub delay: Duration,
|
| 75 |
+
/// The maximum number of retries to return before giving up
|
| 76 |
+
pub max_retries: Option<usize>,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
impl Constant {
|
| 80 |
+
/// Create a new constant retry policy
|
| 81 |
+
pub const fn new(delay: Duration, max_retries: Option<usize>) -> Self {
|
| 82 |
+
Self { delay, max_retries }
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
impl RetryPolicy for Constant {
|
| 87 |
+
fn retry(&self, _error: &Error, last_retry: Option<(usize, Duration)>) -> Option<Duration> {
|
| 88 |
+
if let Some((retry_num, _)) = last_retry {
|
| 89 |
+
if self.max_retries.is_none() || retry_num < self.max_retries.unwrap() {
|
| 90 |
+
Some(self.delay)
|
| 91 |
+
} else {
|
| 92 |
+
None
|
| 93 |
+
}
|
| 94 |
+
} else {
|
| 95 |
+
Some(self.delay)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
fn set_reconnection_time(&mut self, duration: Duration) {
|
| 99 |
+
self.delay = duration;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/// A [`RetryPolicy`] which never retries
|
| 104 |
+
#[derive(Debug, Clone, Copy, Default)]
|
| 105 |
+
pub struct Never;
|
| 106 |
+
|
| 107 |
+
impl RetryPolicy for Never {
|
| 108 |
+
fn retry(&self, _error: &Error, _last_retry: Option<(usize, Duration)>) -> Option<Duration> {
|
| 109 |
+
None
|
| 110 |
+
}
|
| 111 |
+
fn set_reconnection_time(&mut self, _duration: Duration) {}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/// The default [`RetryPolicy`] when initializing an [`EventSource`]
|
| 115 |
+
pub const DEFAULT_RETRY: ExponentialBackoff = ExponentialBackoff::new(
|
| 116 |
+
Duration::from_millis(300),
|
| 117 |
+
2.,
|
| 118 |
+
Some(Duration::from_secs(5)),
|
| 119 |
+
None,
|
| 120 |
+
);
|
crates/forge_infra/src/auth/error.rs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::time::Duration;
|
| 2 |
+
|
| 3 |
+
/// Authentication flow errors.
|
| 4 |
+
#[derive(Debug, Clone, thiserror::Error)]
|
| 5 |
+
pub enum Error {
|
| 6 |
+
/// Authentication initiation failed.
|
| 7 |
+
#[error("Authentication initiation failed: {0}")]
|
| 8 |
+
InitiationFailed(String),
|
| 9 |
+
|
| 10 |
+
/// Authentication timed out waiting for user.
|
| 11 |
+
#[error("Authentication timed out after {0:?}")]
|
| 12 |
+
Timeout(Duration),
|
| 13 |
+
|
| 14 |
+
/// Device code or session expired before completion.
|
| 15 |
+
#[error("Device code or session expired")]
|
| 16 |
+
Expired,
|
| 17 |
+
|
| 18 |
+
/// User denied authorization request.
|
| 19 |
+
#[error("User denied authorization")]
|
| 20 |
+
Denied,
|
| 21 |
+
|
| 22 |
+
/// Polling operation failed due to network or server error.
|
| 23 |
+
#[error("Polling failed: {0}")]
|
| 24 |
+
PollFailed(String),
|
| 25 |
+
|
| 26 |
+
/// Authentication completion failed.
|
| 27 |
+
#[error("Authentication completion failed: {0}")]
|
| 28 |
+
CompletionFailed(String),
|
| 29 |
+
|
| 30 |
+
/// Token refresh operation failed.
|
| 31 |
+
#[error("Token refresh failed: {0}")]
|
| 32 |
+
RefreshFailed(String),
|
| 33 |
+
|
| 34 |
+
/// Invalid authentication context for the flow type.
|
| 35 |
+
#[error("Invalid context: {0}")]
|
| 36 |
+
InvalidContext(String),
|
| 37 |
+
}
|
crates/forge_infra/src/auth/http/anthropic.rs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_app::OAuthHttpProvider;
|
| 2 |
+
use forge_domain::{AuthCodeParams, OAuthConfig, OAuthTokenResponse};
|
| 3 |
+
use oauth2::PkceCodeChallenge;
|
| 4 |
+
use serde::Serialize;
|
| 5 |
+
|
| 6 |
+
use crate::auth::util::build_http_client;
|
| 7 |
+
|
| 8 |
+
/// Anthropic Provider - Non-standard PKCE implementation
|
| 9 |
+
/// Quirk: state parameter equals PKCE verifier
|
| 10 |
+
#[allow(unused)]
|
| 11 |
+
pub struct AnthropicHttpProvider;
|
| 12 |
+
|
| 13 |
+
#[allow(unused)]
|
| 14 |
+
#[derive(Debug, Serialize)]
|
| 15 |
+
struct AnthropicTokenRequest {
|
| 16 |
+
/// Authorization code from callback
|
| 17 |
+
code: String,
|
| 18 |
+
/// State parameter (equals PKCE verifier)
|
| 19 |
+
state: String,
|
| 20 |
+
/// Must be "authorization_code"
|
| 21 |
+
grant_type: String,
|
| 22 |
+
/// OAuth client ID
|
| 23 |
+
client_id: String,
|
| 24 |
+
/// Redirect URI (must match authorization request)
|
| 25 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 26 |
+
redirect_uri: Option<String>,
|
| 27 |
+
/// PKCE code verifier
|
| 28 |
+
code_verifier: String,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[async_trait::async_trait]
|
| 32 |
+
impl OAuthHttpProvider for AnthropicHttpProvider {
|
| 33 |
+
async fn build_auth_url(&self, config: &OAuthConfig) -> anyhow::Result<AuthCodeParams> {
|
| 34 |
+
// Anthropic quirk: state = verifier
|
| 35 |
+
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
|
| 36 |
+
|
| 37 |
+
let mut url = config.auth_url.clone();
|
| 38 |
+
url.query_pairs_mut()
|
| 39 |
+
.append_pair("client_id", &config.client_id)
|
| 40 |
+
.append_pair("response_type", "code")
|
| 41 |
+
.append_pair("scope", &config.scopes.join(" "))
|
| 42 |
+
.append_pair("code_challenge", challenge.as_str())
|
| 43 |
+
.append_pair("code_challenge_method", "S256")
|
| 44 |
+
.append_pair("state", verifier.secret()); // ← Non-standard!
|
| 45 |
+
|
| 46 |
+
if let Some(redirect_uri) = &config.redirect_uri {
|
| 47 |
+
url.query_pairs_mut()
|
| 48 |
+
.append_pair("redirect_uri", redirect_uri);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if let Some(extra_params) = &config.extra_auth_params {
|
| 52 |
+
for (key, value) in extra_params {
|
| 53 |
+
url.query_pairs_mut().append_pair(key, value);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
Ok(AuthCodeParams {
|
| 58 |
+
auth_url: url.to_string(),
|
| 59 |
+
state: verifier.secret().to_string(),
|
| 60 |
+
code_verifier: Some(verifier.secret().to_string()),
|
| 61 |
+
})
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async fn exchange_code(
|
| 65 |
+
&self,
|
| 66 |
+
config: &OAuthConfig,
|
| 67 |
+
code: &str,
|
| 68 |
+
verifier: Option<&str>,
|
| 69 |
+
) -> anyhow::Result<OAuthTokenResponse> {
|
| 70 |
+
// Anthropic-specific token exchange
|
| 71 |
+
let (code, state) = if code.contains('#') {
|
| 72 |
+
let parts: Vec<&str> = code.split('#').collect();
|
| 73 |
+
(
|
| 74 |
+
parts.first().map(|s| s.to_string()).unwrap_or_default(),
|
| 75 |
+
parts.get(1).map(|s| s.to_string()),
|
| 76 |
+
)
|
| 77 |
+
} else {
|
| 78 |
+
(code.to_string(), None)
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
let verifier = verifier
|
| 82 |
+
.ok_or_else(|| anyhow::anyhow!("PKCE verifier required for Anthropic OAuth"))?;
|
| 83 |
+
|
| 84 |
+
let request_body = AnthropicTokenRequest {
|
| 85 |
+
code,
|
| 86 |
+
state: state.unwrap_or_else(|| verifier.to_string()),
|
| 87 |
+
grant_type: "authorization_code".to_string(),
|
| 88 |
+
client_id: config.client_id.to_string(),
|
| 89 |
+
redirect_uri: config.redirect_uri.clone(),
|
| 90 |
+
code_verifier: verifier.to_string(),
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
let client = self.build_http_client(config)?;
|
| 94 |
+
let response = client
|
| 95 |
+
.post(config.token_url.as_str())
|
| 96 |
+
.header("Content-Type", "application/json")
|
| 97 |
+
.json(&request_body)
|
| 98 |
+
.send()
|
| 99 |
+
.await?;
|
| 100 |
+
|
| 101 |
+
if !response.status().is_success() {
|
| 102 |
+
let status = response.status();
|
| 103 |
+
let error_text = response.text().await.unwrap_or_default();
|
| 104 |
+
anyhow::bail!("Token exchange failed with status {status}: {error_text}");
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
Ok(response.json().await?)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/// Create HTTP client with provider-specific headers/behavior
|
| 111 |
+
fn build_http_client(&self, config: &OAuthConfig) -> anyhow::Result<reqwest::Client> {
|
| 112 |
+
build_http_client(config.custom_headers.as_ref())
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
#[cfg(test)]
|
| 117 |
+
mod tests {
|
| 118 |
+
use forge_domain::OAuthConfig;
|
| 119 |
+
use url::Url;
|
| 120 |
+
|
| 121 |
+
use super::*;
|
| 122 |
+
|
| 123 |
+
fn test_oauth_config() -> OAuthConfig {
|
| 124 |
+
OAuthConfig {
|
| 125 |
+
client_id: "test_client".to_string().into(),
|
| 126 |
+
auth_url: Url::parse("https://example.com/auth").unwrap(),
|
| 127 |
+
token_url: Url::parse("https://example.com/token").unwrap(),
|
| 128 |
+
scopes: vec!["read".to_string(), "write".to_string()],
|
| 129 |
+
redirect_uri: Some("https://example.com/callback".to_string()),
|
| 130 |
+
use_pkce: true,
|
| 131 |
+
token_refresh_url: None,
|
| 132 |
+
extra_auth_params: None,
|
| 133 |
+
custom_headers: None,
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#[tokio::test]
|
| 138 |
+
async fn test_anthropic_provider_state_equals_verifier() {
|
| 139 |
+
let provider = AnthropicHttpProvider;
|
| 140 |
+
let config = test_oauth_config();
|
| 141 |
+
|
| 142 |
+
let result = provider.build_auth_url(&config).await.unwrap();
|
| 143 |
+
|
| 144 |
+
// Anthropic quirk: state should equal verifier
|
| 145 |
+
assert_eq!(result.state, result.code_verifier.unwrap());
|
| 146 |
+
assert!(result.auth_url.contains("code_challenge_method=S256"));
|
| 147 |
+
}
|
| 148 |
+
}
|
crates/forge_infra/src/auth/http/github.rs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_app::OAuthHttpProvider;
|
| 2 |
+
use forge_domain::{AuthCodeParams, OAuthConfig, OAuthTokenResponse};
|
| 3 |
+
|
| 4 |
+
use super::standard::StandardHttpProvider;
|
| 5 |
+
use crate::auth::util::build_http_client;
|
| 6 |
+
|
| 7 |
+
/// GitHub Provider - HTTP 200 responses may contain errors
|
| 8 |
+
pub struct GithubHttpProvider;
|
| 9 |
+
|
| 10 |
+
#[async_trait::async_trait]
|
| 11 |
+
impl OAuthHttpProvider for GithubHttpProvider {
|
| 12 |
+
async fn build_auth_url(&self, config: &OAuthConfig) -> anyhow::Result<AuthCodeParams> {
|
| 13 |
+
// Use standard flow - no quirks in auth URL
|
| 14 |
+
StandardHttpProvider.build_auth_url(config).await
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
async fn exchange_code(
|
| 18 |
+
&self,
|
| 19 |
+
config: &OAuthConfig,
|
| 20 |
+
code: &str,
|
| 21 |
+
verifier: Option<&str>,
|
| 22 |
+
) -> anyhow::Result<OAuthTokenResponse> {
|
| 23 |
+
// Use standard exchange - quirks handled in HTTP client via
|
| 24 |
+
// github_compliant_http_request
|
| 25 |
+
StandardHttpProvider
|
| 26 |
+
.exchange_code(config, code, verifier)
|
| 27 |
+
.await
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
fn build_http_client(&self, config: &OAuthConfig) -> anyhow::Result<reqwest::Client> {
|
| 31 |
+
// GitHub quirk: HTTP 200 responses may contain errors
|
| 32 |
+
// This is handled by the github_compliant_http_request function
|
| 33 |
+
build_http_client(config.custom_headers.as_ref())
|
| 34 |
+
}
|
| 35 |
+
}
|
crates/forge_infra/src/auth/http/mod.rs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod anthropic;
|
| 2 |
+
mod github;
|
| 3 |
+
mod standard;
|
| 4 |
+
|
| 5 |
+
pub(crate) use anthropic::AnthropicHttpProvider;
|
| 6 |
+
pub(crate) use github::GithubHttpProvider;
|
| 7 |
+
pub(crate) use standard::StandardHttpProvider;
|
crates/forge_infra/src/auth/http/standard.rs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use forge_app::OAuthHttpProvider;
|
| 2 |
+
use forge_domain::{AuthCodeParams, OAuthConfig, OAuthTokenResponse};
|
| 3 |
+
use oauth2::{CsrfToken, PkceCodeChallenge, Scope};
|
| 4 |
+
use serde::Serialize;
|
| 5 |
+
|
| 6 |
+
use crate::auth::util::*;
|
| 7 |
+
|
| 8 |
+
/// Standard RFC-compliant OAuth provider
|
| 9 |
+
pub struct StandardHttpProvider;
|
| 10 |
+
|
| 11 |
+
#[derive(Debug, Serialize)]
|
| 12 |
+
struct StandardTokenRequest<'a> {
|
| 13 |
+
grant_type: &'static str,
|
| 14 |
+
code: &'a str,
|
| 15 |
+
client_id: &'a str,
|
| 16 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 17 |
+
redirect_uri: Option<&'a str>,
|
| 18 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 19 |
+
code_verifier: Option<&'a str>,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
#[async_trait::async_trait]
|
| 23 |
+
impl OAuthHttpProvider for StandardHttpProvider {
|
| 24 |
+
async fn build_auth_url(&self, config: &OAuthConfig) -> anyhow::Result<AuthCodeParams> {
|
| 25 |
+
// Use oauth2 library - standard flow
|
| 26 |
+
use oauth2::{AuthUrl, ClientId, TokenUrl};
|
| 27 |
+
|
| 28 |
+
let mut client =
|
| 29 |
+
oauth2::basic::BasicClient::new(ClientId::new(config.client_id.to_string()))
|
| 30 |
+
.set_auth_uri(AuthUrl::new(config.auth_url.to_string())?)
|
| 31 |
+
.set_token_uri(TokenUrl::new(config.token_url.to_string())?);
|
| 32 |
+
|
| 33 |
+
if let Some(redirect_uri) = &config.redirect_uri {
|
| 34 |
+
client = client.set_redirect_uri(oauth2::RedirectUrl::new(redirect_uri.clone())?);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
let mut request = client.authorize_url(CsrfToken::new_random);
|
| 38 |
+
|
| 39 |
+
for scope in &config.scopes {
|
| 40 |
+
request = request.add_scope(Scope::new(scope.clone()));
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if let Some(extra_params) = &config.extra_auth_params {
|
| 44 |
+
for (key, value) in extra_params {
|
| 45 |
+
request = request.add_extra_param(key, value);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
let (auth_url, csrf_state, pkce_verifier) = if config.use_pkce {
|
| 50 |
+
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
|
| 51 |
+
let (url, state) = request.set_pkce_challenge(challenge).url();
|
| 52 |
+
(url, state, Some(verifier))
|
| 53 |
+
} else {
|
| 54 |
+
let (url, state) = request.url();
|
| 55 |
+
(url, state, None)
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
Ok(AuthCodeParams {
|
| 59 |
+
auth_url: auth_url.to_string(),
|
| 60 |
+
state: csrf_state.secret().to_string(),
|
| 61 |
+
code_verifier: pkce_verifier.map(|v| v.secret().to_string()),
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async fn exchange_code(
|
| 66 |
+
&self,
|
| 67 |
+
config: &OAuthConfig,
|
| 68 |
+
code: &str,
|
| 69 |
+
verifier: Option<&str>,
|
| 70 |
+
) -> anyhow::Result<OAuthTokenResponse> {
|
| 71 |
+
let http_client = self.build_http_client(config)?;
|
| 72 |
+
let request_body = StandardTokenRequest {
|
| 73 |
+
grant_type: "authorization_code",
|
| 74 |
+
code,
|
| 75 |
+
client_id: config.client_id.as_ref(),
|
| 76 |
+
redirect_uri: config.redirect_uri.as_deref(),
|
| 77 |
+
code_verifier: verifier,
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
let response = http_client
|
| 81 |
+
.post(config.token_url.as_str())
|
| 82 |
+
.header("Content-Type", "application/x-www-form-urlencoded")
|
| 83 |
+
.header("Accept", "application/json")
|
| 84 |
+
.body(serde_urlencoded::to_string(&request_body)?)
|
| 85 |
+
.send()
|
| 86 |
+
.await?;
|
| 87 |
+
|
| 88 |
+
let status = response.status();
|
| 89 |
+
let body = response.text().await?;
|
| 90 |
+
|
| 91 |
+
if !status.is_success() {
|
| 92 |
+
anyhow::bail!("OAuth token exchange failed ({status}): {body}");
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Parse the raw token payload so provider-specific fields like
|
| 96 |
+
// `id_token` are preserved instead of being dropped by generic helpers.
|
| 97 |
+
Ok(parse_token_response(&body)?)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/// Create HTTP client with provider-specific headers/behavior
|
| 101 |
+
fn build_http_client(&self, config: &OAuthConfig) -> anyhow::Result<reqwest::Client> {
|
| 102 |
+
build_http_client(config.custom_headers.as_ref())
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
#[cfg(test)]
|
| 107 |
+
mod tests {
|
| 108 |
+
use forge_domain::OAuthConfig;
|
| 109 |
+
use url::Url;
|
| 110 |
+
|
| 111 |
+
use super::*;
|
| 112 |
+
|
| 113 |
+
fn test_oauth_config() -> OAuthConfig {
|
| 114 |
+
OAuthConfig {
|
| 115 |
+
client_id: "test_client".to_string().into(),
|
| 116 |
+
auth_url: Url::parse("https://example.com/auth").unwrap(),
|
| 117 |
+
token_url: Url::parse("https://example.com/token").unwrap(),
|
| 118 |
+
scopes: vec!["read".to_string(), "write".to_string()],
|
| 119 |
+
redirect_uri: Some("https://example.com/callback".to_string()),
|
| 120 |
+
use_pkce: true,
|
| 121 |
+
token_refresh_url: None,
|
| 122 |
+
extra_auth_params: None,
|
| 123 |
+
custom_headers: None,
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#[tokio::test]
|
| 128 |
+
async fn test_standard_provider_build_auth_url() {
|
| 129 |
+
let provider = StandardHttpProvider;
|
| 130 |
+
let config = test_oauth_config();
|
| 131 |
+
|
| 132 |
+
let result = provider.build_auth_url(&config).await.unwrap();
|
| 133 |
+
|
| 134 |
+
assert!(result.auth_url.contains("client_id=test_client"));
|
| 135 |
+
assert!(result.auth_url.contains("response_type=code"));
|
| 136 |
+
assert!(result.code_verifier.is_some());
|
| 137 |
+
assert_ne!(&result.state, result.code_verifier.as_ref().unwrap());
|
| 138 |
+
}
|
| 139 |
+
}
|