Skip to content

Commit a863ae0

Browse files
committed
refactor: modularize cli and internalize competitor analysis
1 parent 3a7e5b8 commit a863ae0

21 files changed

Lines changed: 2311 additions & 1625 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ All notable user-visible changes are documented in this file.
1818

1919
- `loopforge onboard` no longer reports built-in starter success unless the expected artifact is actually created, and runtime JSON fallback now recognizes `function_name`-style tool calls emitted by real Ollama-compatible models.
2020

21+
### Changed
22+
23+
- Competitor-analysis posts were moved out of `docs-site/` into `docs/internal/competitive/`, and public blog/homepage references to OpenFang/OpenClaw were removed.
24+
- `loopforge release check` now fails when public docs contain competitor-analysis terms or when semver tags on HEAD do not match the requested release tag.
25+
2126
## [1.1.0] - 2026-03-06
2227

2328
### Added

crates/loopforge-cli/src/acp.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
use anyhow::Context;
2+
use rexos::memory::MemoryStore;
3+
4+
pub(crate) fn load_acp_events(
5+
memory: &MemoryStore,
6+
session: Option<&str>,
7+
limit: usize,
8+
) -> anyhow::Result<Vec<serde_json::Value>> {
9+
let raw = memory
10+
.kv_get("rexos.acp.events")
11+
.context("kv_get rexos.acp.events")?
12+
.unwrap_or_else(|| "[]".to_string());
13+
let mut events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
14+
15+
if let Some(session) = session {
16+
let session = session.trim();
17+
if !session.is_empty() {
18+
events.retain(|ev| ev.get("session_id").and_then(|v| v.as_str()) == Some(session));
19+
}
20+
}
21+
22+
let wanted = limit.max(1);
23+
if events.len() > wanted {
24+
events = events.split_off(events.len() - wanted);
25+
}
26+
Ok(events)
27+
}
28+
29+
pub(crate) fn load_acp_checkpoints(
30+
memory: &MemoryStore,
31+
session: &str,
32+
) -> anyhow::Result<Vec<serde_json::Value>> {
33+
let session = session.trim();
34+
if session.is_empty() {
35+
anyhow::bail!("session is empty");
36+
}
37+
let key = format!("rexos.acp.checkpoints.{session}");
38+
let raw = memory
39+
.kv_get(&key)
40+
.with_context(|| format!("kv_get {key}"))?
41+
.unwrap_or_else(|| "[]".to_string());
42+
let checkpoints: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
43+
Ok(checkpoints)
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use super::*;
49+
use rexos::paths::RexosPaths;
50+
use tempfile::tempdir;
51+
52+
#[test]
53+
fn load_acp_events_filters_by_session_and_limit() {
54+
let tmp = tempdir().unwrap();
55+
let paths = RexosPaths {
56+
base_dir: tmp.path().join(".loopforge"),
57+
};
58+
paths.ensure_dirs().unwrap();
59+
let memory = MemoryStore::open_or_create(&paths).unwrap();
60+
memory
61+
.kv_set(
62+
"rexos.acp.events",
63+
r#"[
64+
{"session_id":"s-1","step":1},
65+
{"session_id":"s-2","step":2},
66+
{"session_id":"s-1","step":3}
67+
]"#,
68+
)
69+
.unwrap();
70+
71+
let events = load_acp_events(&memory, Some("s-1"), 1).unwrap();
72+
assert_eq!(events.len(), 1);
73+
assert_eq!(events[0].get("step").and_then(|v| v.as_i64()), Some(3));
74+
}
75+
76+
#[test]
77+
fn load_acp_checkpoints_rejects_empty_session() {
78+
let tmp = tempdir().unwrap();
79+
let paths = RexosPaths {
80+
base_dir: tmp.path().join(".loopforge"),
81+
};
82+
paths.ensure_dirs().unwrap();
83+
let memory = MemoryStore::open_or_create(&paths).unwrap();
84+
85+
let err = load_acp_checkpoints(&memory, " ").expect_err("empty session should fail");
86+
assert!(err.to_string().contains("session is empty"));
87+
}
88+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use rexos::{config::RexosConfig, paths::RexosPaths};
2+
3+
#[derive(Debug, Clone, serde::Serialize)]
4+
pub(crate) struct ConfigValidationReport {
5+
pub(crate) valid: bool,
6+
pub(crate) config_path: String,
7+
pub(crate) errors: Vec<String>,
8+
}
9+
10+
pub(crate) fn validate_config(paths: &RexosPaths) -> ConfigValidationReport {
11+
let config_path = paths.config_path();
12+
let display_path = config_path.display().to_string();
13+
let raw = match std::fs::read_to_string(&config_path) {
14+
Ok(raw) => raw,
15+
Err(e) => {
16+
return ConfigValidationReport {
17+
valid: false,
18+
config_path: display_path,
19+
errors: vec![format!("read config failed: {e}")],
20+
};
21+
}
22+
};
23+
24+
let cfg: RexosConfig = match toml::from_str(&raw) {
25+
Ok(cfg) => cfg,
26+
Err(e) => {
27+
return ConfigValidationReport {
28+
valid: false,
29+
config_path: display_path,
30+
errors: vec![format!("parse config TOML failed: {e}")],
31+
};
32+
}
33+
};
34+
35+
let mut errors = Vec::new();
36+
for (route_name, provider_name) in [
37+
("planning", cfg.router.planning.provider.trim()),
38+
("coding", cfg.router.coding.provider.trim()),
39+
("summary", cfg.router.summary.provider.trim()),
40+
] {
41+
if provider_name.is_empty() {
42+
errors.push(format!("router.{route_name}.provider is empty"));
43+
continue;
44+
}
45+
if !cfg.providers.contains_key(provider_name) {
46+
errors.push(format!(
47+
"router.{route_name}.provider references unknown provider '{provider_name}'"
48+
));
49+
}
50+
}
51+
52+
ConfigValidationReport {
53+
valid: errors.is_empty(),
54+
config_path: display_path,
55+
errors,
56+
}
57+
}
58+
59+
#[cfg(test)]
60+
mod tests {
61+
use super::*;
62+
use tempfile::tempdir;
63+
64+
#[test]
65+
fn validate_config_reports_success_for_default_config() {
66+
let tmp = tempdir().unwrap();
67+
let paths = RexosPaths {
68+
base_dir: tmp.path().join(".loopforge"),
69+
};
70+
paths.ensure_dirs().unwrap();
71+
RexosConfig::ensure_default(&paths).unwrap();
72+
73+
let report = validate_config(&paths);
74+
assert!(report.valid, "expected config valid, got {report:?}");
75+
assert!(
76+
report.errors.is_empty(),
77+
"expected no errors, got {report:?}"
78+
);
79+
}
80+
81+
#[test]
82+
fn validate_config_reports_parse_error_for_invalid_toml() {
83+
let tmp = tempdir().unwrap();
84+
let paths = RexosPaths {
85+
base_dir: tmp.path().join(".loopforge"),
86+
};
87+
paths.ensure_dirs().unwrap();
88+
std::fs::write(
89+
paths.config_path(),
90+
"[providers
91+
broken = true",
92+
)
93+
.unwrap();
94+
95+
let report = validate_config(&paths);
96+
assert!(!report.valid, "expected config invalid, got {report:?}");
97+
assert!(
98+
report
99+
.errors
100+
.iter()
101+
.any(|e| e.contains("parse config TOML")),
102+
"expected parse error, got {report:?}"
103+
);
104+
}
105+
}

0 commit comments

Comments
 (0)