use std::collections::BTreeSet;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use crate::builder::ulid_gen::new_ulid;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Bucket {
Allowed,
NeedsApproval,
Blocked,
}
pub(crate) trait CapabilityPolicy {
fn bucket_of_key(&self, key: &str) -> Option<Bucket>;
fn required_approvals(&self, key: &str, bucket: Bucket) -> u8;
fn known_capability_keys(&self) -> Vec<&str>;
fn blocked_hint(&self, key: &str) -> String;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum State {
Suggested,
Approved,
Rejected,
Applied,
}
pub(crate) fn state_label(s: State) -> &'static str {
match s {
State::Suggested => "suggested",
State::Approved => "approved",
State::Rejected => "rejected",
State::Applied => "applied",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Approval {
pub(crate) by: String,
pub(crate) at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StagedChange {
pub(crate) path: String,
pub(crate) content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Proposal {
pub(crate) id: String,
pub(crate) capability: String,
pub(crate) title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) summary: Option<String>,
pub(crate) bucket: Bucket,
pub(crate) required_approvals: u8,
pub(crate) state: State,
pub(crate) created_at: String,
pub(crate) created_by: String,
pub(crate) changes: Vec<StagedChange>,
pub(crate) approvals: Vec<Approval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reject_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) decided_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) decided_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) applied_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) metadata: Option<serde_json::Value>,
}
impl Proposal {
pub(crate) fn short(&self) -> &str {
&self.id[self.id.len().saturating_sub(8)..]
}
pub(crate) fn distinct_approvals(&self) -> usize {
self.approvals
.iter()
.map(|a| a.by.as_str())
.collect::<BTreeSet<_>>()
.len()
}
pub(crate) fn is_applyable(&self) -> bool {
match self.state {
State::Approved => true,
State::Suggested => self.required_approvals == 0,
State::Rejected | State::Applied => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LogEntry {
pub(crate) id: String,
pub(crate) ts: String,
pub(crate) event: String,
pub(crate) proposal: String,
pub(crate) capability: String,
pub(crate) by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) detail: Option<String>,
}
pub(crate) struct Store {
root: PathBuf,
subdir: &'static str,
}
impl Store {
pub(crate) fn new(root: impl Into<PathBuf>, subdir: &'static str) -> Self {
Store {
root: root.into(),
subdir,
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
fn dir(&self) -> PathBuf {
self.root.join(".rustio").join(self.subdir)
}
fn proposals_dir(&self) -> PathBuf {
self.dir().join("proposals")
}
fn log_path(&self) -> PathBuf {
self.dir().join("log.jsonl")
}
pub(crate) fn save(&self, p: &Proposal) -> Result<(), String> {
let dir = self.proposals_dir();
fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.json", p.id));
let json = serde_json::to_string_pretty(p)
.map_err(|e| format!("could not encode proposal: {e}"))?;
fs::write(&path, json).map_err(|e| format!("could not write {}: {e}", path.display()))
}
pub(crate) fn load_all(&self) -> Result<Vec<Proposal>, String> {
let dir = self.proposals_dir();
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("could not read {}: {e}", dir.display())),
};
let mut out = Vec::new();
for entry in entries {
let path = entry
.map_err(|e| format!("could not read {}: {e}", dir.display()))?
.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
let p: Proposal = serde_json::from_str(&raw)
.map_err(|e| format!("{} is not a valid proposal: {e}", path.display()))?;
out.push(p);
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub(crate) fn load(&self, query: &str) -> Result<Proposal, String> {
let all = self.load_all()?;
let matches: Vec<Proposal> = all
.into_iter()
.filter(|p| p.id == query || p.id.ends_with(query) || p.id.starts_with(query))
.collect();
match matches.len() {
0 => Err(format!("no proposal matches {query:?}")),
1 => Ok(matches.into_iter().next().expect("len == 1")),
n => Err(format!("{n} proposals match {query:?}; use a longer id")),
}
}
pub(crate) fn append_log(&self, entry: &LogEntry) -> Result<(), String> {
let dir = self.dir();
fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
let mut line =
serde_json::to_string(entry).map_err(|e| format!("could not encode log entry: {e}"))?;
line.push('\n');
let path = self.log_path();
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("could not open {}: {e}", path.display()))?;
f.write_all(line.as_bytes())
.map_err(|e| format!("could not write {}: {e}", path.display()))
}
pub(crate) fn read_log(&self) -> Vec<LogEntry> {
let raw = match fs::read_to_string(self.log_path()) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
raw.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub(crate) fn recent_log(&self, n: usize) -> Vec<LogEntry> {
let mut entries = self.read_log();
let start = entries.len().saturating_sub(n);
entries.split_off(start)
}
}
pub(crate) fn now_ts() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
pub(crate) fn log_entry(
event: &str,
proposal: &str,
capability: &str,
by: &str,
detail: Option<String>,
) -> LogEntry {
LogEntry {
id: new_ulid(),
ts: now_ts(),
event: event.to_string(),
proposal: proposal.to_string(),
capability: capability.to_string(),
by: by.to_string(),
detail,
}
}
pub(crate) fn do_propose(
store: &Store,
policy: &dyn CapabilityPolicy,
capability: &str,
title: &str,
summary: Option<String>,
changes: Vec<StagedChange>,
actor: String,
) -> Result<Proposal, String> {
do_propose_meta(
store, policy, capability, title, summary, changes, None, 0, actor,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn do_propose_meta(
store: &Store,
policy: &dyn CapabilityPolicy,
capability: &str,
title: &str,
summary: Option<String>,
changes: Vec<StagedChange>,
metadata: Option<serde_json::Value>,
required_floor: u8,
actor: String,
) -> Result<Proposal, String> {
let bucket = policy.bucket_of_key(capability).ok_or_else(|| {
let known = policy.known_capability_keys();
format!(
"unknown capability {capability:?}. Known capabilities: {}",
known.join(", ")
)
})?;
if bucket == Bucket::Blocked {
store.append_log(&log_entry(
"blocked",
"-",
capability,
&actor,
Some(title.to_string()),
))?;
return Err(policy.blocked_hint(capability));
}
let p = Proposal {
id: new_ulid(),
capability: capability.to_string(),
title: title.to_string(),
summary,
bucket,
required_approvals: policy
.required_approvals(capability, bucket)
.max(required_floor),
state: State::Suggested,
created_at: now_ts(),
created_by: actor,
changes,
approvals: Vec::new(),
reject_reason: None,
decided_by: None,
decided_at: None,
applied_at: None,
metadata,
};
store.save(&p)?;
store.append_log(&log_entry(
"suggested",
&p.id,
&p.capability,
&p.created_by,
Some(p.title.clone()),
))?;
Ok(p)
}
pub(crate) fn do_approve(
store: &Store,
id: &str,
actor: String,
apply_cmd: &str,
) -> Result<Proposal, String> {
let mut p = store.load(id)?;
match p.state {
State::Suggested => {}
State::Approved => {
return Err(format!(
"proposal {} is already approved — run `{apply_cmd} {}`",
p.short(),
p.short()
))
}
State::Applied => return Err(format!("proposal {} was already applied", p.short())),
State::Rejected => {
return Err(format!(
"proposal {} was rejected; it cannot be approved",
p.short()
))
}
}
if p.required_approvals == 0 {
return Err(format!(
"`{}` is Allowed — no approval needed. Run `{apply_cmd} {}`.",
p.capability,
p.short()
));
}
if p.approvals.iter().any(|a| a.by == actor) {
return Err(format!(
"{actor} has already approved this; a second, distinct approver is required"
));
}
p.approvals.push(Approval {
by: actor.clone(),
at: now_ts(),
});
if p.distinct_approvals() as u8 >= p.required_approvals {
p.state = State::Approved;
}
store.save(&p)?;
store.append_log(&log_entry("approved", &p.id, &p.capability, &actor, None))?;
Ok(p)
}
pub(crate) fn do_reject(
store: &Store,
id: &str,
reason: &str,
actor: String,
) -> Result<Proposal, String> {
let mut p = store.load(id)?;
match p.state {
State::Suggested | State::Approved => {}
State::Applied => return Err(format!("proposal {} was already applied", p.short())),
State::Rejected => return Err(format!("proposal {} is already rejected", p.short())),
}
p.state = State::Rejected;
p.reject_reason = Some(reason.to_string());
p.decided_by = Some(actor.clone());
p.decided_at = Some(now_ts());
store.save(&p)?;
store.append_log(&log_entry(
"rejected",
&p.id,
&p.capability,
&actor,
Some(reason.to_string()),
))?;
Ok(p)
}
pub(crate) fn do_apply_with<F>(
store: &Store,
id: &str,
actor: String,
approve_cmd: &str,
apply_fn: F,
) -> Result<(Proposal, Vec<String>), String>
where
F: FnOnce(&Proposal, &Path) -> Result<Vec<String>, String>,
{
let mut p = store.load(id)?;
match p.state {
State::Applied => return Err(format!("proposal {} was already applied", p.short())),
State::Rejected => return Err(format!("proposal {} was rejected", p.short())),
State::Suggested | State::Approved => {}
}
if !p.is_applyable() {
return Err(format!(
"proposal {} needs approval first ({}/{} approvals). Run `{approve_cmd} {} --by <name>`.",
p.short(),
p.distinct_approvals(),
p.required_approvals,
p.short()
));
}
let written = apply_fn(&p, store.root())?;
p.state = State::Applied;
p.applied_at = Some(now_ts());
store.save(&p)?;
store.append_log(&log_entry(
"applied",
&p.id,
&p.capability,
&actor,
Some(format!("{} file(s)", written.len())),
))?;
Ok((p, written))
}