use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::paths::routine_flags_dir;
use crate::utils::atomic::atomic_write;
use crate::utils::time::now_secs;
use super::command::slugify;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum FlagScope {
General,
Local,
}
impl FlagScope {
fn suffix(self) -> &'static str {
match self {
Self::General => ".md",
Self::Local => ".local.md",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, utoipa::ToSchema)]
pub struct Flag {
pub filename: String,
#[serde(rename = "type")]
pub flag_type: String,
pub description: String,
pub scope: FlagScope,
pub created_at: u64,
}
fn is_safe_flag_filename(filename: &str) -> bool {
!filename.is_empty()
&& !filename.contains(['/', '\\'])
&& !filename.contains("..")
&& filename.ends_with(".md")
}
fn parse_filename(filename: &str) -> Option<(u64, FlagScope)> {
let (stem, scope) = if let Some(stem) = filename.strip_suffix(".local.md") {
(stem, FlagScope::Local)
} else if let Some(stem) = filename.strip_suffix(".md") {
(stem, FlagScope::General)
} else {
return None;
};
let (_, ts) = stem.rsplit_once('-')?;
let created_at = ts.parse().ok()?;
Some((created_at, scope))
}
pub fn create_flag(
slug: &str,
flag_type: &str,
description: &str,
scope: FlagScope,
) -> std::io::Result<Flag> {
let flag_type = flag_type.trim();
let description = description.trim();
let dir = routine_flags_dir(slug);
std::fs::create_dir_all(&dir)?;
let type_slug = slugify(flag_type);
let mut created_at = now_secs();
let filename = loop {
let candidate = format!("{type_slug}-{created_at}{}", scope.suffix());
if !dir.join(&candidate).exists() {
break candidate;
}
created_at += 1;
};
atomic_write(
&dir.join(&filename),
format!("{flag_type}\n\n{description}\n").as_bytes(),
)?;
Ok(Flag {
filename,
flag_type: flag_type.to_string(),
description: description.to_string(),
scope,
created_at,
})
}
pub fn list_flags(slug: &str) -> Vec<Flag> {
let dir = routine_flags_dir(slug);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut flags: Vec<Flag> = entries
.flatten()
.filter_map(|entry| {
let filename = entry.file_name().to_string_lossy().into_owned();
let (created_at, scope) = parse_filename(&filename)?;
let text = std::fs::read_to_string(entry.path()).ok()?;
let mut parts = text.splitn(2, "\n\n");
let flag_type = parts.next().unwrap_or_default().trim().to_string();
let description = parts.next().unwrap_or_default().trim().to_string();
Some(Flag {
filename,
flag_type,
description,
scope,
created_at,
})
})
.collect();
flags.sort_by_key(|flag| flag.created_at);
flags
}
pub fn resolve_flag(slug: &str, filename: &str) -> std::io::Result<bool> {
if !is_safe_flag_filename(filename) {
return Ok(false);
}
let path = routine_flags_dir(slug).join(filename);
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(&path)?;
Ok(true)
}
#[cfg(test)]
#[path = "flags_tests.rs"]
mod flags_tests;