use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(skip)]
pub gov_root: PathBuf,
#[serde(default)]
pub project: ProjectConfig,
#[serde(default)]
pub paths: PathsConfig,
#[serde(default)]
pub schema: SchemaConfig,
#[serde(default)]
pub source_scan: SourceScanConfig,
#[serde(default)]
pub work_item: WorkItemConfig,
#[serde(default)]
pub verification: VerificationConfig,
#[serde(default)]
pub concurrency: ConcurrencyConfig,
#[serde(default)]
pub tags: TagsConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
gov_root: PathBuf::from("gov"),
project: ProjectConfig::default(),
paths: PathsConfig::default(),
schema: SchemaConfig::default(),
source_scan: SourceScanConfig::default(),
work_item: WorkItemConfig::default(),
verification: VerificationConfig::default(),
concurrency: ConcurrencyConfig::default(),
tags: TagsConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TagsConfig {
#[serde(default)]
pub allowed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VerificationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub default_guards: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConcurrencyConfig {
#[serde(default = "default_lock_timeout_secs")]
pub lock_timeout_secs: u64,
}
fn default_lock_timeout_secs() -> u64 {
30
}
impl Default for ConcurrencyConfig {
fn default() -> Self {
Self {
lock_timeout_secs: default_lock_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default = "default_project_name")]
pub name: String,
#[serde(default = "default_owner")]
pub default_owner: String,
}
fn default_project_name() -> String {
"govctl-project".to_string()
}
fn default_owner() -> String {
if let Ok(owner) = std::env::var("GOVCTL_DEFAULT_OWNER") {
return owner;
}
std::process::Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| format!("@{}", s.trim()))
.filter(|s| s.len() > 1) .unwrap_or_else(|| "@your-handle".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathsConfig {
#[serde(default = "default_docs_output")]
pub docs_output: PathBuf,
#[serde(default = "default_agent_dir")]
pub agent_dir: PathBuf,
}
fn default_docs_output() -> PathBuf {
PathBuf::from("docs")
}
pub fn default_agent_dir() -> PathBuf {
PathBuf::from(".claude")
}
impl Default for PathsConfig {
fn default() -> Self {
Self {
docs_output: default_docs_output(),
agent_dir: default_agent_dir(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaConfig {
#[serde(default = "default_schema_version")]
pub version: u32,
}
fn default_schema_version() -> u32 {
crate::cmd::migrate::CURRENT_SCHEMA_VERSION
}
impl Default for SchemaConfig {
fn default() -> Self {
Self {
version: default_schema_version(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceScanConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_scan_include")]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default = "default_scan_pattern")]
pub pattern: String,
}
fn default_scan_include() -> Vec<String> {
vec![
"src/**/*.rs".to_string(),
"crates/**/*.rs".to_string(),
"**/*.md".to_string(),
]
}
fn default_scan_pattern() -> String {
r"\[\[(RFC-\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\d{4}|WI-\d{4}-\d{2}-\d{2}-(?:[a-f0-9]{4}(?:-\d{3})?|\d{3}))\]\]".to_string()
}
impl Default for SourceScanConfig {
fn default() -> Self {
Self {
enabled: false,
include: default_scan_include(),
exclude: vec![],
pattern: default_scan_pattern(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemConfig {
#[serde(default)]
pub id_strategy: IdStrategy,
}
impl Default for WorkItemConfig {
fn default() -> Self {
Self {
id_strategy: IdStrategy::Sequential,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdStrategy {
#[default]
Sequential,
AuthorHash,
Random,
}
impl IdStrategy {
pub fn get_author_hash() -> Option<String> {
let output = std::process::Command::new("git")
.args(["config", "user.email"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let email = String::from_utf8(output.stdout).ok()?;
let email = email.trim();
if email.is_empty() {
return None;
}
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(email.as_bytes());
let result = hasher.finalize();
Some(format!("{:02x}{:02x}", result[0], result[1]))
}
pub fn generate_random_suffix() -> String {
use rand::RngExt;
let mut rng = rand::rng();
let bytes: [u8; 2] = rng.random();
format!("{:02x}{:02x}", bytes[0], bytes[1])
}
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let config_path = path
.map(PathBuf::from)
.or_else(Self::find_config)
.unwrap_or_else(|| PathBuf::from("gov/config.toml"));
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
let mut config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config: {}", config_path.display()))?;
if let Some(project_root) = config_path.parent().and_then(|p| p.parent()) {
config.gov_root = project_root.join("gov");
if config.paths.docs_output.is_relative() {
config.paths.docs_output = project_root.join(&config.paths.docs_output);
}
if config.paths.agent_dir.is_relative() {
config.paths.agent_dir = project_root.join(&config.paths.agent_dir);
}
}
Ok(config)
} else {
Ok(Config::default())
}
}
fn find_config() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let config_path = current.join("gov/config.toml");
if config_path.exists() {
return Some(config_path);
}
if !current.pop() {
return None;
}
}
}
pub fn rfc_dir(&self) -> PathBuf {
self.gov_root.join("rfc")
}
pub fn adr_dir(&self) -> PathBuf {
self.gov_root.join("adr")
}
pub fn work_dir(&self) -> PathBuf {
self.gov_root.join("work")
}
pub fn schema_dir(&self) -> PathBuf {
self.gov_root.join("schema")
}
pub fn guard_dir(&self) -> PathBuf {
self.gov_root.join("guard")
}
pub fn templates_dir(&self) -> PathBuf {
self.gov_root.join("templates")
}
pub fn rfc_output(&self) -> PathBuf {
self.paths.docs_output.join("rfc")
}
pub fn adr_output(&self) -> PathBuf {
self.paths.docs_output.join("adr")
}
pub fn work_output(&self) -> PathBuf {
self.paths.docs_output.join("work")
}
pub fn releases_path(&self) -> PathBuf {
self.gov_root.join("releases.toml")
}
pub fn display_path(&self, path: &std::path::Path) -> std::path::PathBuf {
self.gov_root
.parent()
.and_then(|root| path.strip_prefix(root).ok())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| path.to_path_buf())
}
pub fn default_toml(schema_version: u32) -> String {
format!(
r#"[project]
name = "my-project"
# Default owner for new RFCs (uses git user.name if not set)
# default_owner = "@your-handle"
[paths]
docs_output = "docs"
# AI agent directory — target for `govctl init-skills`
# Contains skills/ and agents/ subdirs
# Default: ".claude" (Claude Code / Claude Desktop)
# For Cursor: ".cursor"
# For Windsurf: ".windsurf"
# agent_dir = ".claude"
[schema]
version = {schema_version}
# [work_item]
# ID strategy for work items (default: sequential)
# - sequential: WI-YYYY-MM-DD-NNN (solo projects)
# - author-hash: WI-YYYY-MM-DD-{{hash}}-NNN (multi-person teams, uses git email)
# - random: WI-YYYY-MM-DD-{{rand}} (simple uniqueness)
# id_strategy = "author-hash"
# [verification]
# Enable project-level default verification guards.
# enabled = true
# default_guards = ["GUARD-GOVCTL-CHECK", "GUARD-CARGO-TEST"]
# [source_scan]
# Scan source files for [[artifact-id]] references during `govctl check`
# enabled = false
# include = ["src/**/*.rs", "crates/**/*.rs", "**/*.md"]
# exclude = []
# [concurrency]
# Maximum seconds to wait for exclusive lock before failing (default: 30)
# Implements [[RFC-0004]] concurrent write safety
# lock_timeout_secs = 30
# [tags]
# Controlled-vocabulary tags for artifact classification — [[RFC-0002:C-RESOURCES]]
# Artifacts may only use tags listed here.
# allowed = ["security", "breaking-change", "performance"]
"#
)
}
}