SaylorTwift HF Staff commited on
Commit
1851bae
·
verified ·
1 Parent(s): c981f27

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. crates/forge_ci/src/jobs/bounty_job.rs +74 -0
  2. crates/forge_ci/src/jobs/build.rs +9 -0
  3. crates/forge_ci/src/jobs/draft_release_update_job.rs +21 -0
  4. crates/forge_ci/src/jobs/label_sync_job.rs +20 -0
  5. crates/forge_ci/src/jobs/lint.rs +68 -0
  6. crates/forge_ci/src/jobs/mod.rs +21 -0
  7. crates/forge_ci/src/jobs/release_build_job.rs +103 -0
  8. crates/forge_ci/src/jobs/release_draft.rs +33 -0
  9. crates/forge_ci/src/jobs/release_draft_pr.rs +22 -0
  10. crates/forge_ci/src/jobs/release_homebrew.rs +16 -0
  11. crates/forge_ci/src/jobs/release_npm.rs +35 -0
  12. crates/forge_ci/src/lib.rs +4 -0
  13. crates/forge_ci/src/release_matrix.rs +100 -0
  14. crates/forge_ci/src/steps/mod.rs +3 -0
  15. crates/forge_ci/src/steps/setup_protoc.rs +11 -0
  16. crates/forge_ci/src/workflows/autofix.rs +57 -0
  17. crates/forge_ci/src/workflows/bounty.rs +47 -0
  18. crates/forge_ci/src/workflows/ci.rs +82 -0
  19. crates/forge_ci/src/workflows/labels.rs +21 -0
  20. crates/forge_ci/src/workflows/mod.rs +17 -0
  21. crates/forge_ci/src/workflows/release_drafter.rs +36 -0
  22. crates/forge_ci/src/workflows/release_publish.rs +32 -0
  23. crates/forge_ci/src/workflows/stale.rs +48 -0
  24. crates/forge_ci/tests/ci.rs +36 -0
  25. crates/forge_config/src/auto_dump.rs +25 -0
  26. crates/forge_config/src/compact.rs +266 -0
  27. crates/forge_config/src/config.rs +516 -0
  28. crates/forge_config/src/decimal.rs +94 -0
  29. crates/forge_config/src/error.rs +18 -0
  30. crates/forge_config/src/http.rs +95 -0
  31. crates/forge_config/src/legacy.rs +81 -0
  32. crates/forge_config/src/lib.rs +29 -0
  33. crates/forge_config/src/model.rs +26 -0
  34. crates/forge_config/src/percentage.rs +136 -0
  35. crates/forge_config/src/reader.rs +314 -0
  36. crates/forge_config/src/reasoning.rs +57 -0
  37. crates/forge_config/src/retry.rs +48 -0
  38. crates/forge_config/src/writer.rs +40 -0
  39. crates/forge_config/tests/schema.rs +30 -0
  40. crates/forge_embed/src/lib.rs +39 -0
  41. crates/forge_eventsource/src/error.rs +59 -0
  42. crates/forge_eventsource/src/event_source.rs +290 -0
  43. crates/forge_eventsource/src/lib.rs +33 -0
  44. crates/forge_eventsource/src/reqwest_ext.rs +17 -0
  45. crates/forge_eventsource/src/retry.rs +120 -0
  46. crates/forge_infra/src/auth/error.rs +37 -0
  47. crates/forge_infra/src/auth/http/anthropic.rs +148 -0
  48. crates/forge_infra/src/auth/http/github.rs +35 -0
  49. crates/forge_infra/src/auth/http/mod.rs +7 -0
  50. 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
+ }