feat(notforge): add Gitea API foundation
This commit is contained in:
33
crates/notforge/Cargo.toml
Normal file
33
crates/notforge/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "notforge"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
description = "Forge lifecycle and repository provisioning for notfiles"
|
||||
|
||||
[lib]
|
||||
name = "notforge"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "notforge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
notcore = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
176
crates/notforge/src/config.rs
Normal file
176
crates/notforge/src/config.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::NotforgeError;
|
||||
|
||||
/// Top-level forge configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForgeConfig {
|
||||
/// Gitea backend configuration.
|
||||
pub gitea: GiteaConfig,
|
||||
/// Repositories managed by this forge configuration.
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<RepoSpec>,
|
||||
}
|
||||
|
||||
/// Gitea backend configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GiteaConfig {
|
||||
/// Base HTTP URL for the Gitea API.
|
||||
pub base_url: String,
|
||||
/// Default owner namespace for repositories.
|
||||
pub owner: String,
|
||||
/// Hostname used in generated SSH clone URLs.
|
||||
pub ssh_host: String,
|
||||
/// SSH port used in generated SSH clone URLs.
|
||||
pub ssh_port: u16,
|
||||
/// Backend mode used to verify or start Gitea.
|
||||
pub mode: GiteaMode,
|
||||
/// Authentication configuration for Gitea API calls.
|
||||
#[serde(default)]
|
||||
pub auth: ForgeAuthConfig,
|
||||
}
|
||||
|
||||
/// Supported Gitea backend modes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum GiteaMode {
|
||||
/// Run Gitea directly as a local process.
|
||||
LocalProcess(LocalProcessConfig),
|
||||
/// Run Gitea via a local container runtime.
|
||||
LocalContainer(LocalContainerConfig),
|
||||
/// Verify or start Gitea as a systemd service on a VM.
|
||||
VmSystemd(VmSystemdConfig),
|
||||
/// Use an already-running Gitea instance.
|
||||
Existing,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GiteaMode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawMode {
|
||||
Name(String),
|
||||
Tagged {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
config: Option<toml::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
let raw = RawMode::deserialize(deserializer)?;
|
||||
match raw {
|
||||
RawMode::Name(name) if name == "existing" => Ok(Self::Existing),
|
||||
RawMode::Name(name) => Err(serde::de::Error::custom(format!(
|
||||
"unknown gitea mode '{name}'"
|
||||
))),
|
||||
RawMode::Tagged { kind, config } => match kind.as_str() {
|
||||
"existing" => Ok(Self::Existing),
|
||||
"local-process" => Ok(Self::LocalProcess(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("local-process requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
"local-container" => Ok(Self::LocalContainer(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("local-container requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
"vm-systemd" => Ok(Self::VmSystemd(
|
||||
config
|
||||
.ok_or_else(|| serde::de::Error::custom("vm-systemd requires config"))?
|
||||
.try_into()
|
||||
.map_err(serde::de::Error::custom)?,
|
||||
)),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"unknown gitea mode '{other}'"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local process runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalProcessConfig {
|
||||
/// Path or command name for the Gitea binary.
|
||||
pub binary: String,
|
||||
/// Working directory for the Gitea process.
|
||||
pub work_dir: String,
|
||||
/// Gitea app.ini path.
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
/// Local container runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalContainerConfig {
|
||||
/// Container runtime executable, such as docker or podman.
|
||||
pub runtime: String,
|
||||
/// Container name to verify or start.
|
||||
pub container_name: String,
|
||||
/// Container image to run when missing.
|
||||
pub image: String,
|
||||
/// Host data directory mounted into the container.
|
||||
pub data_dir: String,
|
||||
}
|
||||
|
||||
/// Remote VM systemd runner configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VmSystemdConfig {
|
||||
/// SSH user for the VM.
|
||||
pub ssh_user: String,
|
||||
/// SSH host for the VM.
|
||||
pub ssh_host: String,
|
||||
/// systemd service name for Gitea.
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
/// Authentication configuration for Gitea API calls.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ForgeAuthConfig {
|
||||
/// Token secret reference, preferred when available.
|
||||
pub token: Option<ForgeSecretRef>,
|
||||
/// Username secret reference for basic auth fallback.
|
||||
pub username: Option<ForgeSecretRef>,
|
||||
/// Password secret reference for basic auth fallback.
|
||||
pub password: Option<ForgeSecretRef>,
|
||||
}
|
||||
|
||||
/// Secret reference used by notforge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
pub enum ForgeSecretRef {
|
||||
/// Resolve from an environment variable.
|
||||
Env { key: String },
|
||||
/// Resolve from a 1Password URI.
|
||||
Op { uri: String },
|
||||
}
|
||||
|
||||
/// Repository specification managed by a forge config.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepoSpec {
|
||||
/// Repository owner namespace.
|
||||
pub owner: String,
|
||||
/// Repository name.
|
||||
pub name: String,
|
||||
/// Whether the repository should be private.
|
||||
pub private: bool,
|
||||
/// Optional repository description.
|
||||
pub description: Option<String>,
|
||||
/// Local git remote name.
|
||||
pub remote_name: String,
|
||||
}
|
||||
|
||||
/// Load a forge configuration from TOML.
|
||||
pub fn load_config(path: &Path) -> Result<ForgeConfig, NotforgeError> {
|
||||
notcore::config::load_toml_file(
|
||||
path,
|
||||
|path, err| NotforgeError::Config(format!("reading {}: {err}", path.display())),
|
||||
|path, err| NotforgeError::Config(format!("parsing {}: {err}", path.display())),
|
||||
)
|
||||
}
|
||||
36
crates/notforge/src/error.rs
Normal file
36
crates/notforge/src/error.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error type for forge lifecycle, API, git, and secret-resolution failures.
|
||||
#[derive(Debug, Diagnostic, Error)]
|
||||
pub enum NotforgeError {
|
||||
/// Configuration could not be loaded or parsed.
|
||||
#[error("configuration error: {0}")]
|
||||
#[diagnostic(code(notforge::config))]
|
||||
Config(String),
|
||||
|
||||
/// A required secret could not be resolved.
|
||||
#[error("secret resolution error: {0}")]
|
||||
#[diagnostic(code(notforge::secret))]
|
||||
Secret(String),
|
||||
|
||||
/// Forge API communication failed.
|
||||
#[error("HTTP error: {0}")]
|
||||
#[diagnostic(code(notforge::http))]
|
||||
Http(String),
|
||||
|
||||
/// A lifecycle command failed.
|
||||
#[error("command error: {0}")]
|
||||
#[diagnostic(code(notforge::command))]
|
||||
Command(String),
|
||||
|
||||
/// A git operation failed.
|
||||
#[error("git error: {0}")]
|
||||
#[diagnostic(code(notforge::git))]
|
||||
Git(String),
|
||||
|
||||
/// Repository lookup or provisioning failed.
|
||||
#[error("repository error: {0}")]
|
||||
#[diagnostic(code(notforge::repository))]
|
||||
Repository(String),
|
||||
}
|
||||
302
crates/notforge/src/gitea.rs
Normal file
302
crates/notforge/src/gitea.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::RepoSpec;
|
||||
use crate::error::NotforgeError;
|
||||
use crate::ports::{ForgeApi, ForgeAuth, ForgeVersion, RemoteRepository};
|
||||
|
||||
/// HTTP method used by the Gitea transport boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpMethod {
|
||||
/// HTTP GET.
|
||||
Get,
|
||||
/// HTTP POST.
|
||||
Post,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpMethod {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Get => formatter.write_str("GET"),
|
||||
Self::Post => formatter.write_str("POST"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level request sent by the Gitea adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GiteaHttpRequest {
|
||||
/// HTTP method.
|
||||
pub method: HttpMethod,
|
||||
/// Absolute URL to call.
|
||||
pub url: String,
|
||||
/// Optional Authorization header value.
|
||||
pub authorization: Option<String>,
|
||||
/// Optional JSON body.
|
||||
pub body: Option<Value>,
|
||||
}
|
||||
|
||||
/// Transport-level response returned to the Gitea adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GiteaHttpResponse {
|
||||
/// HTTP status code.
|
||||
pub status: u16,
|
||||
/// Parsed JSON response body, or `null` for an empty body.
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
/// HTTP transport boundary for Gitea API requests.
|
||||
pub trait GiteaTransport {
|
||||
/// Send a transport-level request.
|
||||
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Gitea API adapter that implements [`ForgeApi`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GiteaHttpApi<T = UreqGiteaTransport> {
|
||||
base_url: String,
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl GiteaHttpApi<UreqGiteaTransport> {
|
||||
/// Create a Gitea API adapter backed by the default blocking HTTP transport.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_transport(base_url, UreqGiteaTransport::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GiteaHttpApi<T>
|
||||
where
|
||||
T: GiteaTransport,
|
||||
{
|
||||
/// Create a Gitea API adapter with an injected transport.
|
||||
pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base_url, path)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
self.transport.send(GiteaHttpRequest {
|
||||
method: HttpMethod::Get,
|
||||
url: self.url(path),
|
||||
authorization: None,
|
||||
body: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn post(
|
||||
&self,
|
||||
path: &str,
|
||||
auth: &ForgeAuth,
|
||||
body: Value,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
self.transport.send(GiteaHttpRequest {
|
||||
method: HttpMethod::Post,
|
||||
url: self.url(path),
|
||||
authorization: Some(authorization_header(auth)),
|
||||
body: Some(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ForgeApi for GiteaHttpApi<T>
|
||||
where
|
||||
T: GiteaTransport,
|
||||
{
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
|
||||
let response = self.get("/api/v1/version")?;
|
||||
require_status(response.status, &[200], "get Gitea version")?;
|
||||
let payload: GiteaVersionResponse = parse_body(response.body, "Gitea version response")?;
|
||||
Ok(ForgeVersion {
|
||||
version: payload.version,
|
||||
})
|
||||
}
|
||||
|
||||
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError> {
|
||||
let response = self.get(&format!("/api/v1/repos/{owner}/{name}"))?;
|
||||
if response.status == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
require_status(response.status, &[200], "get Gitea repository")?;
|
||||
let payload: GiteaRepositoryResponse =
|
||||
parse_body(response.body, "Gitea repository response")?;
|
||||
Ok(Some(payload.into_remote_repository()))
|
||||
}
|
||||
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError> {
|
||||
let response = self.post("/api/v1/user/repos", auth, create_repo_body(spec))?;
|
||||
require_status(response.status, &[200, 201], "create Gitea repository")?;
|
||||
let payload: GiteaRepositoryResponse =
|
||||
parse_body(response.body, "Gitea repository response")?;
|
||||
Ok(payload.into_remote_repository())
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking HTTP transport implemented with `ureq`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UreqGiteaTransport {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl Default for UreqGiteaTransport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UreqGiteaTransport {
|
||||
/// Create a transport with a custom `ureq` agent.
|
||||
pub fn new(agent: ureq::Agent) -> Self {
|
||||
Self { agent }
|
||||
}
|
||||
}
|
||||
|
||||
impl GiteaTransport for UreqGiteaTransport {
|
||||
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
let method = request.method;
|
||||
let url = request.url.clone();
|
||||
let mut call = match method {
|
||||
HttpMethod::Get => self.agent.get(&url),
|
||||
HttpMethod::Post => self.agent.post(&url),
|
||||
}
|
||||
.set("Accept", "application/json");
|
||||
|
||||
if let Some(authorization) = request.authorization {
|
||||
call = call.set("Authorization", &authorization);
|
||||
}
|
||||
|
||||
let result = match method {
|
||||
HttpMethod::Get => call.call(),
|
||||
HttpMethod::Post => call
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(request.body.unwrap_or_else(|| json!({}))),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(response) => response_from_ureq(response),
|
||||
Err(ureq::Error::Status(status, response)) => response_from_status(status, response),
|
||||
Err(err) => Err(NotforgeError::Http(format!(
|
||||
"{method} {url} failed before response: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaVersionResponse {
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRepositoryResponse {
|
||||
owner: GiteaOwnerResponse,
|
||||
name: String,
|
||||
clone_url: String,
|
||||
ssh_url: String,
|
||||
}
|
||||
|
||||
impl GiteaRepositoryResponse {
|
||||
fn into_remote_repository(self) -> RemoteRepository {
|
||||
RemoteRepository {
|
||||
owner: self.owner.login,
|
||||
name: self.name,
|
||||
clone_url: self.clone_url,
|
||||
ssh_url: self.ssh_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaOwnerResponse {
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateRepoRequest<'a> {
|
||||
name: &'a str,
|
||||
private: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn create_repo_body(spec: &RepoSpec) -> Value {
|
||||
serde_json::to_value(CreateRepoRequest {
|
||||
name: &spec.name,
|
||||
private: spec.private,
|
||||
description: spec.description.as_deref(),
|
||||
})
|
||||
.expect("CreateRepoRequest serialization should not fail")
|
||||
}
|
||||
|
||||
fn authorization_header(auth: &ForgeAuth) -> String {
|
||||
match auth {
|
||||
ForgeAuth::Token { token } => format!("token {token}"),
|
||||
ForgeAuth::Basic { username, password } => {
|
||||
format!(
|
||||
"Basic {}",
|
||||
STANDARD.encode(format!("{username}:{password}"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_status(status: u16, expected: &[u16], action: &str) -> Result<(), NotforgeError> {
|
||||
if expected.contains(&status) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NotforgeError::Http(format!(
|
||||
"{action} returned unexpected status {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_body<T>(body: Value, context: &str) -> Result<T, NotforgeError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
serde_json::from_value(body)
|
||||
.map_err(|err| NotforgeError::Http(format!("invalid {context}: {err}")))
|
||||
}
|
||||
|
||||
fn response_from_status(
|
||||
status: u16,
|
||||
response: ureq::Response,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
response_from_parts(status, response)
|
||||
}
|
||||
|
||||
fn response_from_ureq(response: ureq::Response) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
response_from_parts(response.status(), response)
|
||||
}
|
||||
|
||||
fn response_from_parts(
|
||||
status: u16,
|
||||
response: ureq::Response,
|
||||
) -> Result<GiteaHttpResponse, NotforgeError> {
|
||||
let body = response
|
||||
.into_string()
|
||||
.map_err(|err| NotforgeError::Http(format!("reading HTTP response body: {err}")))?;
|
||||
let body = if body.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|err| NotforgeError::Http(format!("parsing HTTP response JSON: {err}")))?
|
||||
};
|
||||
|
||||
Ok(GiteaHttpResponse { status, body })
|
||||
}
|
||||
19
crates/notforge/src/lib.rs
Normal file
19
crates/notforge/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Forge lifecycle and repository provisioning for notfiles.
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod gitea;
|
||||
pub mod ports;
|
||||
|
||||
/// Current `notforge` crate version.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exposes_package_version() {
|
||||
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
}
|
||||
3
crates/notforge/src/main.rs
Normal file
3
crates/notforge/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("{}", notforge::VERSION);
|
||||
}
|
||||
126
crates/notforge/src/ports.rs
Normal file
126
crates/notforge/src/ports.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{ForgeConfig, ForgeSecretRef, RepoSpec};
|
||||
use crate::error::NotforgeError;
|
||||
|
||||
/// Authentication material resolved for forge API requests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ForgeAuth {
|
||||
/// Token-based Gitea API authentication.
|
||||
Token { token: String },
|
||||
/// Basic authentication fallback for bootstrap flows.
|
||||
Basic { username: String, password: String },
|
||||
}
|
||||
|
||||
/// Remote repository metadata returned by a forge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteRepository {
|
||||
/// Repository owner namespace.
|
||||
pub owner: String,
|
||||
/// Repository name.
|
||||
pub name: String,
|
||||
/// HTTP clone URL.
|
||||
pub clone_url: String,
|
||||
/// SSH clone URL.
|
||||
pub ssh_url: String,
|
||||
}
|
||||
|
||||
/// Forge version metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ForgeVersion {
|
||||
/// Server version string.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Local git repository path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalRepository {
|
||||
/// Filesystem path to the local repository.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Result of ensuring a forge backend is available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LifecycleStatus {
|
||||
/// The forge was already reachable.
|
||||
AlreadyAvailable,
|
||||
/// The forge was started by this operation.
|
||||
Started,
|
||||
/// The forge was verified without needing a start operation.
|
||||
Verified,
|
||||
}
|
||||
|
||||
/// Result of setting a local git remote.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteStatus {
|
||||
/// The remote was added.
|
||||
Added,
|
||||
/// The remote existed and was updated.
|
||||
Updated,
|
||||
/// The remote already matched the desired URL.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Result of pushing a local branch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PushStatus {
|
||||
/// The branch was pushed.
|
||||
Pushed,
|
||||
/// The branch already matched the remote.
|
||||
AlreadyUpToDate,
|
||||
}
|
||||
|
||||
/// Port for forge API operations.
|
||||
pub trait ForgeApi {
|
||||
/// Return server version metadata.
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError>;
|
||||
|
||||
/// Look up a repository by owner and name.
|
||||
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError>;
|
||||
|
||||
/// Create a repository from a spec and resolved auth.
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for local or remote forge lifecycle operations.
|
||||
pub trait ForgeLifecycle {
|
||||
/// Ensure the configured forge backend is available.
|
||||
fn ensure_available(&self, config: &ForgeConfig) -> Result<LifecycleStatus, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for git remote and push operations.
|
||||
pub trait GitRemoteManager {
|
||||
/// Return the URL for a local remote if it exists.
|
||||
fn remote_url(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
) -> Result<Option<String>, NotforgeError>;
|
||||
|
||||
/// Add or update a local git remote.
|
||||
fn set_remote(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
url: &str,
|
||||
) -> Result<RemoteStatus, NotforgeError>;
|
||||
|
||||
/// Push a branch to a local remote.
|
||||
fn push(
|
||||
&self,
|
||||
repo: &LocalRepository,
|
||||
remote_name: &str,
|
||||
branch: &str,
|
||||
set_upstream: bool,
|
||||
) -> Result<PushStatus, NotforgeError>;
|
||||
}
|
||||
|
||||
/// Port for resolving forge secrets.
|
||||
pub trait SecretResolverPort {
|
||||
/// Resolve a secret reference into an in-memory secret value.
|
||||
fn resolve(&self, secret: &ForgeSecretRef) -> Result<String, NotforgeError>;
|
||||
}
|
||||
222
crates/notforge/tests/api.rs
Normal file
222
crates/notforge/tests/api.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use notforge::config::{
|
||||
ForgeAuthConfig, ForgeConfig, ForgeSecretRef, GiteaMode, RepoSpec, VmSystemdConfig, load_config,
|
||||
};
|
||||
use notforge::error::NotforgeError;
|
||||
use notforge::ports::{
|
||||
ForgeApi, ForgeAuth, ForgeLifecycle, ForgeVersion, GitRemoteManager, LifecycleStatus,
|
||||
LocalRepository, PushStatus, RemoteRepository, RemoteStatus, SecretResolverPort,
|
||||
};
|
||||
|
||||
fn assert_diagnostic<E: miette::Diagnostic>() {}
|
||||
|
||||
#[test]
|
||||
fn notforge_error_implements_miette_diagnostic() {
|
||||
assert_diagnostic::<NotforgeError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_parses_vm_systemd_gitea_mode() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config_path = dir.path().join("notforge.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
[gitea]
|
||||
base_url = "http://gitea.local:3000"
|
||||
owner = "joe"
|
||||
ssh_host = "gitea.local"
|
||||
ssh_port = 2222
|
||||
|
||||
[gitea.mode]
|
||||
type = "vm-systemd"
|
||||
|
||||
[gitea.mode.config]
|
||||
ssh_user = "dev"
|
||||
ssh_host = "gitea.local"
|
||||
service_name = "gitea"
|
||||
|
||||
[gitea.auth.token]
|
||||
source = "env"
|
||||
key = "GITEA_TOKEN"
|
||||
|
||||
[[repositories]]
|
||||
owner = "joe"
|
||||
name = "notfiles-config"
|
||||
private = true
|
||||
description = "Personal config payload"
|
||||
remote_name = "gitea"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(&config_path).unwrap();
|
||||
|
||||
assert_eq!(config.gitea.base_url, "http://gitea.local:3000");
|
||||
assert_eq!(config.gitea.owner, "joe");
|
||||
assert_eq!(config.gitea.ssh_port, 2222);
|
||||
assert_eq!(
|
||||
config.gitea.auth.token,
|
||||
Some(ForgeSecretRef::Env {
|
||||
key: "GITEA_TOKEN".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
config.gitea.mode,
|
||||
GiteaMode::VmSystemd(VmSystemdConfig {
|
||||
ssh_user: "dev".to_string(),
|
||||
ssh_host: "gitea.local".to_string(),
|
||||
service_name: "gitea".to_string(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
config.repositories,
|
||||
vec![RepoSpec {
|
||||
owner: "joe".to_string(),
|
||||
name: "notfiles-config".to_string(),
|
||||
private: true,
|
||||
description: Some("Personal config payload".to_string()),
|
||||
remote_name: "gitea".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_repositories_to_empty() {
|
||||
let config: ForgeConfig = toml::from_str(
|
||||
r#"
|
||||
[gitea]
|
||||
base_url = "http://localhost:3000"
|
||||
owner = "joe"
|
||||
ssh_host = "localhost"
|
||||
ssh_port = 2222
|
||||
mode = "existing"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.repositories.is_empty());
|
||||
assert_eq!(config.gitea.auth, ForgeAuthConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_are_object_safe_for_adapters() {
|
||||
struct FakeApi;
|
||||
impl ForgeApi for FakeApi {
|
||||
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
|
||||
Ok(ForgeVersion {
|
||||
version: "1.0.0".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn repo(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_name: &str,
|
||||
) -> Result<Option<RemoteRepository>, NotforgeError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn create_repo(
|
||||
&self,
|
||||
spec: &RepoSpec,
|
||||
_auth: &ForgeAuth,
|
||||
) -> Result<RemoteRepository, NotforgeError> {
|
||||
Ok(RemoteRepository {
|
||||
owner: spec.owner.clone(),
|
||||
name: spec.name.clone(),
|
||||
clone_url: "http://example/repo.git".to_string(),
|
||||
ssh_url: "ssh://git@example/joe/repo.git".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeLifecycle;
|
||||
impl ForgeLifecycle for FakeLifecycle {
|
||||
fn ensure_available(
|
||||
&self,
|
||||
_config: &ForgeConfig,
|
||||
) -> Result<LifecycleStatus, NotforgeError> {
|
||||
Ok(LifecycleStatus::AlreadyAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeGit;
|
||||
impl GitRemoteManager for FakeGit {
|
||||
fn remote_url(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
) -> Result<Option<String>, NotforgeError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_remote(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
_url: &str,
|
||||
) -> Result<RemoteStatus, NotforgeError> {
|
||||
Ok(RemoteStatus::Added)
|
||||
}
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
_repo: &LocalRepository,
|
||||
_remote_name: &str,
|
||||
_branch: &str,
|
||||
_set_upstream: bool,
|
||||
) -> Result<PushStatus, NotforgeError> {
|
||||
Ok(PushStatus::Pushed)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeSecrets;
|
||||
impl SecretResolverPort for FakeSecrets {
|
||||
fn resolve(&self, _secret: &ForgeSecretRef) -> Result<String, NotforgeError> {
|
||||
Ok("secret".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let api: &dyn ForgeApi = &FakeApi;
|
||||
let lifecycle: &dyn ForgeLifecycle = &FakeLifecycle;
|
||||
let git: &dyn GitRemoteManager = &FakeGit;
|
||||
let secrets: &dyn SecretResolverPort = &FakeSecrets;
|
||||
|
||||
assert_eq!(api.version().unwrap().version, "1.0.0");
|
||||
assert_eq!(
|
||||
lifecycle
|
||||
.ensure_available(&ForgeConfig {
|
||||
gitea: notforge::config::GiteaConfig {
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
owner: "joe".to_string(),
|
||||
ssh_host: "localhost".to_string(),
|
||||
ssh_port: 2222,
|
||||
mode: GiteaMode::Existing,
|
||||
auth: ForgeAuthConfig::default(),
|
||||
},
|
||||
repositories: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
LifecycleStatus::AlreadyAvailable
|
||||
);
|
||||
assert_eq!(
|
||||
git.remote_url(
|
||||
&LocalRepository {
|
||||
path: PathBuf::from(".")
|
||||
},
|
||||
"gitea"
|
||||
)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
secrets
|
||||
.resolve(&ForgeSecretRef::Env {
|
||||
key: "GITEA_TOKEN".to_string()
|
||||
})
|
||||
.unwrap(),
|
||||
"secret"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user