mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
Phase 2/3 of the std::fs lint initiative (Phase 1 refactors landed in
commit 9d1c0d98c).
clippy.toml additions (appended to disallowed-methods):
std::fs::read, read_to_string, write, read_dir, copy, canonicalize
std::fs::File::open, File::create, File::create_new
std::fs::OpenOptions::open
File::options was deliberately excluded — it returns an OpenOptions
builder with no syscall. OpenOptions::open is where the block happens.
Non-blocking std::fs items (metadata, exists, create_dir_all, remove_*,
rename, and all std::fs types) remain legal.
Annotation policy (per updated plan):
- Mixed async/sync production source: function- or statement-scoped
#[expect(...)] so future accidental Tokio-path regressions in the
same file still fire.
- Fully-sync production source, test modules, integration tests,
build.rs: file-level #![expect(...)].
- Every #[expect] has a specific reason identifying the sync context.
Annotations added in ~90 files across the workspace. Notable narrow
placements: fabro-server server.rs current_server_target,
build_disk_usage_response, create_test_app_state_with_session_key;
fabro-server install.rs read_to_string rollback snapshot;
fabro-sandbox local.rs list_recursive; fabro-agent cli.rs FOLLOW-UP on
the JSON-stdout writer; fabro-llm providers/common.rs FOLLOW-UP for
load_file_as_base64 (7 translator call sites; revisit if file:// URL
usage grows).
build.rs blanket allows: fabro-api/build.rs, fabro-util/build.rs.
Pre-existing unrelated nightly-clippy warnings fixed under scope:
fabro-sandbox sandbox_spec.rs (unused_imports, unused_async),
reconnect.rs (unused_variables, unused_async).
Verified: cargo +nightly-2026-04-14 clippy --workspace --all-targets
-- -D warnings passes; fmt clean; 4129/4131 tests pass (two known
flakes under parallel nextest load, both pass individually and are
unrelated to this change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
539 lines
17 KiB
Rust
539 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
use std::path::Path;
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::state::{
|
|
AppOptions, AppState, Comment, Installation, IssueContent, ManifestConversion, OwnerType,
|
|
Project, ProjectItem, PullRequest, Release, Repository, StatusOption, TokenInfo,
|
|
WebhookOptions,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
pub struct FixtureState {
|
|
#[serde(default)]
|
|
pub apps: Vec<FixtureApp>,
|
|
#[serde(default)]
|
|
pub installations: Vec<FixtureInstallation>,
|
|
#[serde(default)]
|
|
pub repositories: Vec<FixtureRepository>,
|
|
#[serde(default)]
|
|
pub pull_requests: Vec<FixturePullRequest>,
|
|
#[serde(default)]
|
|
pub active_tokens: Vec<FixtureActiveToken>,
|
|
#[serde(default)]
|
|
pub projects: Vec<FixtureProject>,
|
|
#[serde(default)]
|
|
pub releases: Vec<FixtureRelease>,
|
|
#[serde(default)]
|
|
pub manifest_conversions: Vec<FixtureManifestConversion>,
|
|
#[serde(default)]
|
|
pub comments: Vec<FixtureComment>,
|
|
#[serde(default)]
|
|
pub webhook_config: FixtureWebhookOptions,
|
|
pub next_installation_id: Option<u64>,
|
|
pub next_pr_number: Option<u64>,
|
|
pub viewer_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureApp {
|
|
pub app_id: String,
|
|
pub slug: String,
|
|
pub owner_login: String,
|
|
pub public: bool,
|
|
pub private_key_pem: String,
|
|
pub webhook_secret: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureInstallation {
|
|
pub id: u64,
|
|
pub app_id: String,
|
|
pub owner: String,
|
|
#[serde(default)]
|
|
pub repositories: Vec<String>,
|
|
#[serde(default)]
|
|
pub suspended: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureRepository {
|
|
pub owner: String,
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub branches: Vec<String>,
|
|
pub default_branch: Option<String>,
|
|
#[serde(default)]
|
|
pub private: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixturePullRequest {
|
|
pub owner: String,
|
|
pub repo: String,
|
|
#[serde(flatten)]
|
|
pub pull_request: PullRequest,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureActiveToken {
|
|
pub token: String,
|
|
pub app_id: String,
|
|
pub installation_id: u64,
|
|
#[serde(default)]
|
|
pub repositories: Vec<String>,
|
|
#[serde(default = "empty_json_object")]
|
|
pub permissions: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureProject {
|
|
pub node_id: String,
|
|
pub number: u64,
|
|
pub owner: String,
|
|
pub owner_type: FixtureOwnerType,
|
|
pub status_field_id: String,
|
|
#[serde(default)]
|
|
pub status_options: Vec<FixtureStatusOption>,
|
|
#[serde(default)]
|
|
pub items: Vec<FixtureProjectItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum FixtureOwnerType {
|
|
Organization,
|
|
User,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureStatusOption {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureProjectItem {
|
|
pub id: String,
|
|
pub status: String,
|
|
pub content: FixtureIssueContent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureIssueContent {
|
|
pub id: String,
|
|
pub number: u64,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub url: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
#[serde(default)]
|
|
pub assignee_ids: Vec<String>,
|
|
#[serde(default)]
|
|
pub labels: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureRelease {
|
|
pub owner: String,
|
|
pub repo: String,
|
|
pub tag_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureManifestConversion {
|
|
pub code: String,
|
|
pub app_id: i64,
|
|
pub slug: String,
|
|
pub client_id: String,
|
|
pub client_secret: String,
|
|
pub webhook_secret: Option<String>,
|
|
pub pem: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct FixtureComment {
|
|
pub issue_node_id: String,
|
|
pub body: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
pub struct FixtureWebhookOptions {
|
|
pub url: Option<String>,
|
|
pub content_type: Option<String>,
|
|
}
|
|
|
|
impl FixtureState {
|
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, String> {
|
|
let path = path.as_ref();
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "twin test harness: sync fixture load from disk; not on a Tokio hot path"
|
|
)]
|
|
let contents = fs::read_to_string(path)
|
|
.map_err(|err| format!("failed to read fixture {}: {err}", path.display()))?;
|
|
serde_json::from_str(&contents)
|
|
.map_err(|err| format!("failed to parse fixture {}: {err}", path.display()))
|
|
}
|
|
|
|
pub fn into_app_state(self) -> Result<AppState, String> {
|
|
let mut state = AppState::new();
|
|
|
|
for app in self.apps {
|
|
let app_id = app.app_id.clone();
|
|
let config = AppOptions {
|
|
app_id: app.app_id,
|
|
slug: app.slug,
|
|
owner_login: app.owner_login,
|
|
public: app.public,
|
|
private_key_pem: app.private_key_pem,
|
|
webhook_secret: app.webhook_secret,
|
|
};
|
|
|
|
catch_unwind(AssertUnwindSafe(|| state.register_app(config)))
|
|
.map_err(|_| format!("invalid RSA private key PEM for fixture app {app_id}"))?;
|
|
}
|
|
|
|
state.installations = self
|
|
.installations
|
|
.into_iter()
|
|
.map(|installation| Installation {
|
|
id: installation.id,
|
|
app_id: installation.app_id,
|
|
owner: installation.owner,
|
|
repositories: installation.repositories,
|
|
suspended: installation.suspended,
|
|
})
|
|
.collect();
|
|
|
|
state.repositories = self
|
|
.repositories
|
|
.into_iter()
|
|
.map(|repository| Repository {
|
|
owner: repository.owner,
|
|
name: repository.name,
|
|
branches: repository.branches,
|
|
default_branch: repository
|
|
.default_branch
|
|
.unwrap_or_else(|| "main".to_string()),
|
|
private: repository.private,
|
|
git_dir: None,
|
|
})
|
|
.collect();
|
|
|
|
for pull_request in self.pull_requests {
|
|
state
|
|
.pull_requests
|
|
.entry((pull_request.owner, pull_request.repo))
|
|
.or_default()
|
|
.push(pull_request.pull_request);
|
|
}
|
|
|
|
state.active_tokens = self
|
|
.active_tokens
|
|
.into_iter()
|
|
.map(|token| {
|
|
(token.token, TokenInfo {
|
|
app_id: token.app_id,
|
|
installation_id: token.installation_id,
|
|
repositories: token.repositories,
|
|
permissions: token.permissions,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
state.projects = self
|
|
.projects
|
|
.into_iter()
|
|
.map(|project| Project {
|
|
node_id: project.node_id,
|
|
number: project.number,
|
|
owner: project.owner,
|
|
owner_type: match project.owner_type {
|
|
FixtureOwnerType::Organization => OwnerType::Organization,
|
|
FixtureOwnerType::User => OwnerType::User,
|
|
},
|
|
status_field_id: project.status_field_id,
|
|
status_options: project
|
|
.status_options
|
|
.into_iter()
|
|
.map(|option| StatusOption {
|
|
id: option.id,
|
|
name: option.name,
|
|
})
|
|
.collect(),
|
|
items: project
|
|
.items
|
|
.into_iter()
|
|
.map(|item| ProjectItem {
|
|
id: item.id,
|
|
status: item.status,
|
|
content: IssueContent {
|
|
id: item.content.id,
|
|
number: item.content.number,
|
|
title: item.content.title,
|
|
body: item.content.body,
|
|
url: item.content.url,
|
|
created_at: item.content.created_at,
|
|
updated_at: item.content.updated_at,
|
|
assignee_ids: item.content.assignee_ids,
|
|
labels: item.content.labels,
|
|
},
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
|
|
state.releases = self
|
|
.releases
|
|
.into_iter()
|
|
.map(|release| {
|
|
((release.owner, release.repo), Release {
|
|
tag_name: release.tag_name,
|
|
})
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
state.manifest_conversions = self
|
|
.manifest_conversions
|
|
.into_iter()
|
|
.map(|conversion| {
|
|
let code = conversion.code.clone();
|
|
(code, ManifestConversion {
|
|
code: conversion.code,
|
|
app_id: conversion.app_id,
|
|
slug: conversion.slug,
|
|
client_id: conversion.client_id,
|
|
client_secret: conversion.client_secret,
|
|
webhook_secret: conversion.webhook_secret,
|
|
pem: conversion.pem,
|
|
})
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
state.comments = self
|
|
.comments
|
|
.into_iter()
|
|
.map(|comment| Comment {
|
|
issue_node_id: comment.issue_node_id,
|
|
body: comment.body,
|
|
})
|
|
.collect();
|
|
|
|
state.webhook_config = WebhookOptions {
|
|
url: self.webhook_config.url,
|
|
content_type: self.webhook_config.content_type,
|
|
};
|
|
|
|
state.next_installation_id = self
|
|
.next_installation_id
|
|
.unwrap_or_else(|| next_installation_id(&state.installations));
|
|
state.next_pr_number = self
|
|
.next_pr_number
|
|
.unwrap_or_else(|| next_pr_number(&state.pull_requests));
|
|
|
|
if let Some(viewer_id) = self.viewer_id {
|
|
state.viewer_id = viewer_id;
|
|
}
|
|
|
|
Ok(state)
|
|
}
|
|
}
|
|
|
|
fn empty_json_object() -> serde_json::Value {
|
|
serde_json::json!({})
|
|
}
|
|
|
|
fn next_installation_id(installations: &[Installation]) -> u64 {
|
|
installations
|
|
.iter()
|
|
.map(|installation| installation.id)
|
|
.max()
|
|
.unwrap_or(0)
|
|
+ 1
|
|
}
|
|
|
|
fn next_pr_number(pull_requests: &HashMap<(String, String), Vec<PullRequest>>) -> u64 {
|
|
pull_requests
|
|
.values()
|
|
.flat_map(|prs| prs.iter().map(|pr| pr.number))
|
|
.max()
|
|
.unwrap_or(0)
|
|
+ 1
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl FixtureState {
|
|
fn single_app_fixture_for_test() -> Self {
|
|
Self {
|
|
apps: vec![FixtureApp {
|
|
app_id: "100".to_string(),
|
|
slug: "fixture-app".to_string(),
|
|
owner_login: "acme".to_string(),
|
|
public: true,
|
|
private_key_pem: test_rsa_key().to_string(),
|
|
webhook_secret: Some("whsec".to_string()),
|
|
}],
|
|
..Self::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_rsa_key() -> &'static str {
|
|
crate::test_support::test_rsa_private_key()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fixture_state_loads_full_server_state() {
|
|
let private_key_pem = test_rsa_key().replace('\n', "\\n");
|
|
let fixture_json = r#"{
|
|
"apps": [{
|
|
"app_id": "100",
|
|
"slug": "ops-app",
|
|
"owner_login": "acme",
|
|
"public": true,
|
|
"private_key_pem": "__PRIVATE_KEY__",
|
|
"webhook_secret": "whsec"
|
|
}],
|
|
"installations": [{
|
|
"id": 7,
|
|
"app_id": "100",
|
|
"owner": "acme",
|
|
"repositories": ["widgets"],
|
|
"suspended": false
|
|
}],
|
|
"repositories": [{
|
|
"owner": "acme",
|
|
"name": "widgets",
|
|
"branches": ["main", "feature"],
|
|
"default_branch": "main"
|
|
}],
|
|
"pull_requests": [{
|
|
"owner": "acme",
|
|
"repo": "widgets",
|
|
"number": 1,
|
|
"node_id": "PR_1",
|
|
"title": "Ship it",
|
|
"body": "Ready",
|
|
"state": "open",
|
|
"draft": false,
|
|
"mergeable": true,
|
|
"additions": 10,
|
|
"deletions": 5,
|
|
"changed_files": 2,
|
|
"html_url": "https://github.com/acme/widgets/pull/1",
|
|
"user_login": "bot",
|
|
"head_ref": "feature",
|
|
"base_ref": "main",
|
|
"created_at": "2026-03-27T00:00:00Z",
|
|
"updated_at": "2026-03-27T00:00:00Z",
|
|
"auto_merge": null
|
|
}],
|
|
"active_tokens": [{
|
|
"token": "ghs_exampletoken",
|
|
"app_id": "100",
|
|
"installation_id": 7,
|
|
"repositories": ["widgets"],
|
|
"permissions": {"contents": "write"}
|
|
}],
|
|
"projects": [{
|
|
"node_id": "PVT_kwDOB",
|
|
"number": 3,
|
|
"owner": "acme",
|
|
"owner_type": "organization",
|
|
"status_field_id": "PVTSSF_1",
|
|
"status_options": [{
|
|
"id": "opt_todo",
|
|
"name": "Todo"
|
|
}],
|
|
"items": [{
|
|
"id": "PVTI_1",
|
|
"status": "Todo",
|
|
"content": {
|
|
"id": "ISSUE_1",
|
|
"number": 42,
|
|
"title": "Track launch",
|
|
"body": "Ship it",
|
|
"url": "https://github.com/acme/widgets/issues/42",
|
|
"created_at": "2026-03-27T00:00:00Z",
|
|
"updated_at": "2026-03-27T00:00:00Z",
|
|
"assignee_ids": ["U_1"],
|
|
"labels": ["priority"]
|
|
}
|
|
}]
|
|
}],
|
|
"releases": [{
|
|
"owner": "acme",
|
|
"repo": "widgets",
|
|
"tag_name": "v1.2.3"
|
|
}],
|
|
"manifest_conversions": [{
|
|
"code": "manifest-code",
|
|
"app_id": 100,
|
|
"slug": "ops-app-dev",
|
|
"client_id": "Iv1.123",
|
|
"client_secret": "manifest-secret",
|
|
"webhook_secret": "manifest-whsec",
|
|
"pem": "manifest pem"
|
|
}],
|
|
"comments": [{
|
|
"issue_node_id": "ISSUE_1",
|
|
"body": "Looks good"
|
|
}],
|
|
"webhook_config": {
|
|
"url": "https://example.com/webhooks/github",
|
|
"content_type": "json"
|
|
},
|
|
"next_installation_id": 99,
|
|
"next_pr_number": 77,
|
|
"viewer_id": "U_seeded"
|
|
}"#
|
|
.replace("__PRIVATE_KEY__", &private_key_pem);
|
|
let fixture: FixtureState = serde_json::from_str(&fixture_json).unwrap();
|
|
|
|
let state = fixture.into_app_state().unwrap();
|
|
|
|
assert_eq!(state.apps["100"].config.slug, "ops-app");
|
|
assert_eq!(state.installations.len(), 1);
|
|
assert_eq!(state.repositories.len(), 1);
|
|
assert_eq!(
|
|
state.pull_requests[&("acme".into(), "widgets".into())].len(),
|
|
1
|
|
);
|
|
assert_eq!(state.active_tokens.len(), 1);
|
|
assert_eq!(state.projects.len(), 1);
|
|
assert_eq!(
|
|
state.releases[&("acme".into(), "widgets".into())].tag_name,
|
|
"v1.2.3"
|
|
);
|
|
assert_eq!(
|
|
state.manifest_conversions["manifest-code"].client_secret,
|
|
"manifest-secret"
|
|
);
|
|
assert_eq!(state.comments.len(), 1);
|
|
assert_eq!(
|
|
state.webhook_config.url.as_deref(),
|
|
Some("https://example.com/webhooks/github")
|
|
);
|
|
assert_eq!(state.webhook_config.content_type.as_deref(), Some("json"));
|
|
assert_eq!(state.next_installation_id, 99);
|
|
assert_eq!(state.next_pr_number, 77);
|
|
assert_eq!(state.viewer_id, "U_seeded");
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_state_derives_public_keys_for_registered_apps() {
|
|
let fixture = FixtureState::single_app_fixture_for_test();
|
|
let state = fixture.into_app_state().unwrap();
|
|
assert!(state.apps["100"].public_key_pem.contains("PUBLIC KEY"));
|
|
}
|
|
}
|