refactor(claw): the forge kind is the integration's — config.rs carries no forge code

The last forge content in config.rs was vocabulary, not data:
`ForgeKind` (name/parse/token_env/default_base) and the pure
`resolve_token` helper. By the same rule that moved `Watch` to the
watch table and left `CalendarWatch` in the calendar integration,
the kind belongs to the crate that speaks the forges.

- `rhema-forge-integration` gains `kind::ForgeKind` and
  `resolve_token`, with the parse and token-fallback tests moved
  alongside — the claw's config tests were testing the integration's
  type through a re-export.
- The claw's surfaces import the type from the integration directly;
  the crate root re-exports it, so `rhema_claw::ForgeKind` keeps
  resolving for everything downstream.

config.rs is now config: credentials, the surface, paths, cadence,
and nothing that knows what a forge or a calendar is.
This commit is contained in:
vhaudiquet committed 2026-10-24 16:48:18 +02:00
1 parent 5047ff8803
commit 90212d1e0f
11 files changed
+160 -150

No files matched your search

+1 -1
View File
@@ -35,8 +35,8 @@ use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::config::ForgeKind;
use crate::watch_store::ClawStore;
use rhema_forge_integration::ForgeKind;
/// `identity_save`: store an account (label, service, url, username, and
/// the secret — required) and optionally register its forge watch.
+19 -140
View File
@@ -1,14 +1,17 @@
//! The claw config file: what to watch, where state lives, and which
//! model answers the dispatches.
//! The claw config file: where state lives, which model answers the
//! dispatches, and the credentials the surface answers to.
//!
//! YAML at `~/.config/rhema/claw.yaml` (`--config` overrides). Paths and
//! credentials fail loudly at startup: a watch with no token, a gitea
//! watch with no host, or an unresolvable state directory never starts a
//! daemon that would poll half-configured. Forge tokens fall back to the
//! per-forge environment variable; the model backend's keys resolve
//! through `rhema-config` exactly as the TUI's do (env first, stored
//! second) and are validated in the binary's startup, not here — the
//! daemon library takes the built provider, so tests can inject a mock.
//! YAML at `~/.config/rhema/claw.yaml` (`--config` overrides). The
//! file carries only what boot needs; everything the daemon
//! reconfigures at runtime — watches, calendars — lives in its tables
//! behind the HTTP doors, validated where it enters. Unresolvable
//! state directories and colliding credentials fail loudly at startup;
//! a daemon that starts with nothing to guard still serves chat.
//! Forge tokens fall back to the per-forge environment variable; the
//! model backend's keys resolve through `rhema-config` exactly as the
//! TUI's do (env first, stored second) and are validated in the
//! binary's startup, not here — the daemon library takes the built
//! provider, so tests can inject a mock.
use std::path::PathBuf;
@@ -31,8 +34,8 @@ pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799";
/// Why a config operation failed.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// The config file does not exist. Claw has nothing to guess from: a
/// daemon without its watch list would silently watch nothing.
/// The config file does not exist. Claw has nothing to guess from:
/// paths and credentials are the file's job.
#[error("no claw config at {0}; pass --config or create one")]
Missing(PathBuf),
/// Reading or writing the file failed.
@@ -76,85 +79,6 @@ pub enum ConfigError {
},
}
/// Which forge a watch polls. The wire spelling is the source name the
/// dispatcher registers and the resource key carries (`github:acme/w#7`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForgeKind {
/// GitHub (api.github.com, or a GitHub Enterprise host).
GitHub,
/// Gitea — self-hosted; a watch must name its instance.
Gitea,
/// GitLab (gitlab.com, or a self-hosted instance).
GitLab,
}
impl ForgeKind {
/// Every kind the claw ships a source for, in registration order.
/// A kind missing here has no watch poller, whatever the table
/// holds.
pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab];
/// The source name this kind registers and dispatches under.
pub fn name(self) -> &'static str {
match self {
ForgeKind::GitHub => "github",
ForgeKind::Gitea => "gitea",
ForgeKind::GitLab => "gitlab",
}
}
/// The environment variable the token falls back to.
pub fn token_env(self) -> &'static str {
match self {
ForgeKind::GitHub => "GITHUB_TOKEN",
ForgeKind::Gitea => "GITEA_TOKEN",
ForgeKind::GitLab => "GITLAB_TOKEN",
}
}
/// Parses a forge name as the config, the HTTP surface, and the
/// watch tools spell it: the source name (`github`, `gitea`,
/// `gitlab`). `None` for anything else.
pub fn parse(name: &str) -> Option<Self> {
match name.trim() {
"github" => Some(ForgeKind::GitHub),
"gitea" => Some(ForgeKind::Gitea),
"gitlab" => Some(ForgeKind::GitLab),
_ => None,
}
}
/// The production base URL; `None` for the self-hosted-only forges.
pub fn default_base(self) -> Option<&'static str> {
match self {
ForgeKind::GitHub => Some(rhema_forge_integration::github::DEFAULT_BASE_URL),
ForgeKind::Gitea => None,
ForgeKind::GitLab => Some(rhema_forge_integration::gitlab::DEFAULT_BASE_URL),
}
}
}
/// Pure token resolution: the config value first, then the forge's env
/// variable; the first non-blank wins. Parameterized over the env lookup
/// so tests can hold the environment still.
pub fn resolve_token(
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
forge: ForgeKind,
stored: Option<&str>,
) -> Option<String> {
stored
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_owned)
.or_else(|| {
get_env(forge.token_env())
.ok()
.map(|token| token.trim().to_owned())
.filter(|token| !token.is_empty())
})
}
/// Whether a `host:port` listen address binds beyond loopback.
/// `localhost` (any case) and any loopback IP literal — `127.x.x.x`,
/// `::1` — are loopback; an empty host (`:7799`, the bind-everything
@@ -388,11 +312,10 @@ impl ClawConfig {
Ok(serde_yaml::from_str(&raw)?)
}
/// The startup checks: every seeded watch resolvable (shape, host,
/// token). Loud, before anything polls. An empty list is fine now —
/// the watch table is seeded from it, and the agent manages its own
/// watches from there; a daemon that starts with nothing to guard
/// still serves chat.
/// The startup checks: the credential classes do not collide, and
/// the surface is open only where it is safe. Loud, before anything
/// listens. Watch and calendar validity is not checked here — those
/// are table rows, validated at their doors.
pub fn validate(&self) -> Result<(), ConfigError> {
// The agent token's safety is that it is scoped; making it the
// human token silently unscopes everything the model's shell
@@ -560,50 +483,6 @@ ingest_token: door-key
assert_eq!(config.listen_addr(), DEFAULT_LISTEN);
assert_eq!(config.token, None);
}
#[test]
fn forge_names_parse_as_the_sources_spell_them() {
assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub));
assert_eq!(ForgeKind::parse(" gitea "), Some(ForgeKind::Gitea));
assert_eq!(ForgeKind::parse("gitlab"), Some(ForgeKind::GitLab));
assert_eq!(ForgeKind::parse("sourcehut"), None);
assert_eq!(ForgeKind::parse(""), None);
for kind in [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab] {
assert_eq!(ForgeKind::parse(kind.name()), Some(kind), "{}", kind.name());
}
}
#[test]
fn the_config_token_wins_over_env_and_blank_counts_as_unset() {
let env = |name: &str| match name {
"GITHUB_TOKEN" => Ok("from-env".to_owned()),
"GITEA_TOKEN" => Ok(" ".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
assert_eq!(
resolve_token(env, ForgeKind::GitHub, Some(" from-config ")),
Some("from-config".to_owned()),
"the config token wins"
);
assert_eq!(
resolve_token(env, ForgeKind::GitHub, None),
Some("from-env".to_owned()),
"the env token serves when no config token"
);
assert_eq!(
resolve_token(env, ForgeKind::Gitea, Some(" ")),
None,
"a blank env token counts as unset"
);
assert_eq!(
resolve_token(
|_| Err(std::env::VarError::NotPresent),
ForgeKind::GitLab,
None
),
None
);
}
#[test]
fn a_non_loopback_surface_demands_a_token() {
let config = |listen: Option<&str>, token: Option<&str>| ClawConfig {
+1 -1
View File
@@ -36,13 +36,13 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
use rhema_core::ToolResolver;
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
use crate::config::ForgeKind;
use crate::consents::WaitBridge;
use crate::daemon::ClawError;
use crate::forge::ForgeSource;
use crate::hub::EventHub;
use rhema_calendar_integration::CalendarSource;
use rhema_calendar_integration::CalendarStore;
use rhema_forge_integration::ForgeKind;
/// The store handles the standard set is assembled over. One struct,
/// so a new extension is a field plus a constructor line, not another
+1 -1
View File
@@ -30,8 +30,8 @@ use rhema_forge_integration::{
use rhema_identity::{IdentityStore, SecretKind};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock};
use crate::config::{ForgeKind, resolve_token};
use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord};
use rhema_forge_integration::{ForgeKind, resolve_token};
/// The cache key of a forge client: everything that changes the client
/// is the forge kind, its host, and the token it authenticates with.
+1 -1
View File
@@ -23,13 +23,13 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use crate::config::ForgeKind;
use crate::forge::{record_client, resolve_watch_token};
use crate::watch_store::ClawStore;
use rhema_agent_tools::AllowAll;
use rhema_autonomous_agent::{Handler, SessionContext, async_trait};
use rhema_coding_agent::{CodingAgent, CodingAgentConfig};
use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session};
use rhema_forge_integration::ForgeKind;
use rhema_forge_integration::triage::Role;
use rhema_forge_integration::{
EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode,
+3 -3
View File
@@ -738,7 +738,7 @@ pub struct WatchBody {
}
async fn watches_add(State(state): State<ClawState>, Json(body): Json<WatchBody>) -> Response {
let Some(kind) = crate::config::ForgeKind::parse(&body.forge) else {
let Some(kind) = rhema_forge_integration::ForgeKind::parse(&body.forge) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "forge must be github, gitea, or gitlab"})),
@@ -783,7 +783,7 @@ async fn watches_remove(
State(state): State<ClawState>,
Path((forge, repo)): Path<(String, String)>,
) -> Response {
let removed = match crate::config::ForgeKind::parse(&forge) {
let removed = match rhema_forge_integration::ForgeKind::parse(&forge) {
Some(kind) => state
.watches
.remove(kind.name(), repo.trim())
@@ -1242,7 +1242,7 @@ fn is_reserved_source(source: &str) -> bool {
matches!(
source,
crate::interactive::SOURCE | crate::task_source::TASKS_SOURCE
) || crate::config::ForgeKind::parse(source).is_some()
) || rhema_forge_integration::ForgeKind::parse(source).is_some()
}
/// The external event's dedup id: a hash of the request's coordinates
+2 -1
View File
@@ -100,7 +100,7 @@ pub use bootstrap::{
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
render_system,
};
pub use config::{ClawConfig, ConfigError, ForgeKind};
pub use config::{ClawConfig, ConfigError};
pub use daemon::{ClawError, Daemon, TickReport, run_once};
pub use environment::write_report;
pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes};
@@ -112,6 +112,7 @@ pub use interactive::InteractiveHandler;
#[cfg(feature = "mock")]
pub use mock::MockLoop;
pub use notifications::{NotificationRecord, NotificationStore, NotificationStoreError};
pub use rhema_forge_integration::{ForgeKind, resolve_token};
pub use settings::SettingsStore;
pub use task_source::{TASKS_SOURCE, TaskScheduler};
pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError};
+1 -1
View File
@@ -22,7 +22,7 @@ use rhema_forge_integration::RepoRef;
use rusqlite::params;
use serde::Serialize;
use crate::config::{ForgeKind, resolve_token};
use rhema_forge_integration::{ForgeKind, resolve_token};
/// One watched repository — the row's vocabulary, and the type the
/// routes parse into. It used to live in the config as the YAML seed's
+1 -1
View File
@@ -18,8 +18,8 @@ use rhema_autonomous_agent::{ToolOp, ToolRegistry, async_trait};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::config::ForgeKind;
use crate::watch_store::{ClawStore, validate_watch};
use rhema_forge_integration::ForgeKind;
/// The compiled operations behind the manifests, by `run.builtin` id.
fn builtins(store: Arc<ClawStore>) -> BTreeMap<String, Arc<dyn ToolOp>> {
@@ -0,0 +1,128 @@
//! The forge kinds: the vocabulary every forge surface shares — the
//! watch table's rows, the account store's `forge` column, the HTTP
//! routes' spelling, the source names the dispatcher registers, the
//! resource keys (`github:acme/w#7`). One type, owned by the crate
//! that speaks the forges; the claw's config no longer carries it.
/// Which forge a watch polls. The wire spelling is the source name the
/// dispatcher registers and the resource key carries (`github:acme/w#7`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForgeKind {
/// GitHub (api.github.com, or a GitHub Enterprise host).
GitHub,
/// Gitea — self-hosted; a watch must name its instance.
Gitea,
/// GitLab (gitlab.com, or a self-hosted instance).
GitLab,
}
impl ForgeKind {
/// Every kind the claw ships a source for, in registration order.
/// A kind missing here has no watch poller, whatever the table
/// holds.
pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab];
/// The source name this kind registers and dispatches under.
pub fn name(self) -> &'static str {
match self {
ForgeKind::GitHub => "github",
ForgeKind::Gitea => "gitea",
ForgeKind::GitLab => "gitlab",
}
}
/// The environment variable the token falls back to.
pub fn token_env(self) -> &'static str {
match self {
ForgeKind::GitHub => "GITHUB_TOKEN",
ForgeKind::Gitea => "GITEA_TOKEN",
ForgeKind::GitLab => "GITLAB_TOKEN",
}
}
/// Parses a forge name as the config, the HTTP surface, and the
/// watch tools spell it: the source name (`github`, `gitea`,
/// `gitlab`). `None` for anything else.
pub fn parse(name: &str) -> Option<Self> {
match name.trim() {
"github" => Some(ForgeKind::GitHub),
"gitea" => Some(ForgeKind::Gitea),
"gitlab" => Some(ForgeKind::GitLab),
_ => None,
}
}
/// The production base URL; `None` for the self-hosted-only forges.
pub fn default_base(self) -> Option<&'static str> {
match self {
ForgeKind::GitHub => Some(crate::github::DEFAULT_BASE_URL),
ForgeKind::Gitea => None,
ForgeKind::GitLab => Some(crate::gitlab::DEFAULT_BASE_URL),
}
}
}
/// Pure token resolution: the stored value first, then the forge's env
/// variable; the first non-blank wins. Parameterized over the env
/// lookup so tests can hold the environment still.
pub fn resolve_token(
get_env: impl Fn(&str) -> Result<String, std::env::VarError>,
forge: ForgeKind,
stored: Option<&str>,
) -> Option<String> {
stored
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_owned)
.or_else(|| {
get_env(forge.token_env())
.ok()
.map(|token| token.trim().to_owned())
.filter(|token| !token.is_empty())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forge_names_parse_as_the_sources_spell_them() {
assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub));
assert_eq!(ForgeKind::parse(" gitea "), Some(ForgeKind::Gitea));
assert_eq!(ForgeKind::parse("gitlab"), Some(ForgeKind::GitLab));
assert_eq!(ForgeKind::parse("sourcehut"), None);
assert_eq!(ForgeKind::parse(""), None);
for kind in ForgeKind::ALL {
assert_eq!(ForgeKind::parse(kind.name()), Some(kind), "{}", kind.name());
}
}
#[test]
fn the_token_falls_back_to_the_env_and_blank_counts_as_unset() {
let env = |name: &str| match name {
"GITHUB_TOKEN" => Ok("from-env".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
assert_eq!(
resolve_token(env, ForgeKind::GitHub, Some(" from-config ")),
Some("from-config".to_owned()),
"the stored token wins"
);
assert_eq!(
resolve_token(env, ForgeKind::GitHub, None),
Some("from-env".to_owned()),
"the env token serves when no stored token"
);
assert_eq!(
resolve_token(
|_| Err(std::env::VarError::NotPresent),
ForgeKind::GitLab,
Some(" ")
),
None,
"a blank token counts as unset"
);
}
}
@@ -52,6 +52,8 @@ pub mod error;
pub mod event;
pub mod forge;
pub mod gitea;
pub mod kind;
pub use kind::{ForgeKind, resolve_token};
pub mod github;
pub mod gitlab;
pub mod triage;