Ticket #10 — introduction du modèle de sprints côté backend. - Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ; rattachement des issues à un sprint (issue.rs) et ports associés. - Application : use-cases sprints (application/src/sprints) + erreurs dédiées. - Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs. - app-tauri : commandes, state et events pour piloter les sprints depuis l'UI. Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store, issue_store, mcp_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
343 lines
11 KiB
Rust
343 lines
11 KiB
Rust
//! Filesystem sprint store.
|
|
//!
|
|
//! Project-scoped sprints live under `<root>/.ideai/sprints/`, one directory per
|
|
//! stable sprint id. The Markdown files are the source of truth; `index.json` is
|
|
//! a reconstructible projection kept in sync after writes.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use domain::{
|
|
IssueActor, ProjectPath, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
|
|
SprintStore, SprintStoreError, SprintVersion,
|
|
};
|
|
|
|
const IDEAI_DIR: &str = ".ideai";
|
|
const SPRINTS_DIR: &str = "sprints";
|
|
const SPRINT_FILE: &str = "sprint.md";
|
|
const INDEX_FILE: &str = "index.json";
|
|
const INDEX_VERSION: u32 = 1;
|
|
|
|
/// Filesystem-backed sprint store.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct FsSprintStore;
|
|
|
|
impl FsSprintStore {
|
|
/// Builds a store.
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct IndexDoc {
|
|
version: u32,
|
|
sprints: Vec<SprintIndexEntry>,
|
|
}
|
|
|
|
fn sprint_root(root: &ProjectPath) -> PathBuf {
|
|
PathBuf::from(root.as_str())
|
|
.join(IDEAI_DIR)
|
|
.join(SPRINTS_DIR)
|
|
}
|
|
|
|
fn sprint_dir(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
|
|
sprint_root(root).join(sprint_id.to_string())
|
|
}
|
|
|
|
fn sprint_path(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
|
|
sprint_dir(root, sprint_id).join(SPRINT_FILE)
|
|
}
|
|
|
|
fn index_path(root: &ProjectPath) -> PathBuf {
|
|
sprint_root(root).join(INDEX_FILE)
|
|
}
|
|
|
|
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), SprintStoreError> {
|
|
let parent = path
|
|
.parent()
|
|
.ok_or_else(|| SprintStoreError::Store("path has no parent".to_owned()))?;
|
|
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
|
let tmp = path.with_extension("tmp");
|
|
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
|
|
tokio::fs::rename(&tmp, path).await.map_err(io_error)
|
|
}
|
|
|
|
async fn read_string(path: &Path) -> Result<String, SprintStoreError> {
|
|
let bytes = tokio::fs::read(path).await.map_err(|err| {
|
|
if err.kind() == std::io::ErrorKind::NotFound {
|
|
SprintStoreError::NotFound
|
|
} else {
|
|
io_error(err)
|
|
}
|
|
})?;
|
|
String::from_utf8(bytes).map_err(|err| SprintStoreError::Store(err.to_string()))
|
|
}
|
|
|
|
fn io_error(err: std::io::Error) -> SprintStoreError {
|
|
SprintStoreError::Store(err.to_string())
|
|
}
|
|
|
|
async fn load_sprint(root: &ProjectPath, sprint_id: SprintId) -> Result<Sprint, SprintStoreError> {
|
|
let text = read_string(&sprint_path(root, sprint_id)).await?;
|
|
parse_sprint_doc(&text)
|
|
}
|
|
|
|
async fn save_sprint(root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
|
|
let dir = sprint_dir(root, sprint.id);
|
|
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
|
write_atomic(&dir.join(SPRINT_FILE), render_sprint_doc(sprint).as_bytes()).await
|
|
}
|
|
|
|
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
|
let dir = sprint_root(root);
|
|
let mut rows = Vec::new();
|
|
let mut entries = match tokio::fs::read_dir(&dir).await {
|
|
Ok(entries) => entries,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
write_index(root, &rows).await?;
|
|
return Ok(rows);
|
|
}
|
|
Err(err) => return Err(io_error(err)),
|
|
};
|
|
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
|
|
let Ok(file_type) = entry.file_type().await else {
|
|
continue;
|
|
};
|
|
if !file_type.is_dir() {
|
|
continue;
|
|
}
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
let Ok(id) = uuid::Uuid::parse_str(&name) else {
|
|
continue;
|
|
};
|
|
if let Ok(sprint) = load_sprint(root, SprintId::from_uuid(id)).await {
|
|
rows.push(SprintIndexEntry::from(&sprint));
|
|
}
|
|
}
|
|
rows.sort_by_key(|row| row.order.get());
|
|
write_index(root, &rows).await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
async fn read_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
|
match tokio::fs::read(index_path(root)).await {
|
|
Ok(bytes) => {
|
|
let doc: IndexDoc = serde_json::from_slice(&bytes)
|
|
.map_err(|err| SprintStoreError::Store(err.to_string()))?;
|
|
Ok(doc.sprints)
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
|
|
Err(err) => Err(io_error(err)),
|
|
}
|
|
}
|
|
|
|
async fn write_index(
|
|
root: &ProjectPath,
|
|
rows: &[SprintIndexEntry],
|
|
) -> Result<(), SprintStoreError> {
|
|
let doc = IndexDoc {
|
|
version: INDEX_VERSION,
|
|
sprints: rows.to_vec(),
|
|
};
|
|
let bytes =
|
|
serde_json::to_vec_pretty(&doc).map_err(|err| SprintStoreError::Store(err.to_string()))?;
|
|
write_atomic(&index_path(root), &bytes).await
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SprintStore for FsSprintStore {
|
|
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
|
|
sprint
|
|
.validate()
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
|
if tokio::fs::try_exists(sprint_path(root, sprint.id))
|
|
.await
|
|
.map_err(io_error)?
|
|
{
|
|
return Err(SprintStoreError::Invalid(format!(
|
|
"sprint {} already exists",
|
|
sprint.id
|
|
)));
|
|
}
|
|
save_sprint(root, sprint).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
root: &ProjectPath,
|
|
sprint_id: SprintId,
|
|
) -> Result<Sprint, SprintStoreError> {
|
|
load_sprint(root, sprint_id).await
|
|
}
|
|
|
|
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
|
let mut rows = read_index(root).await?;
|
|
rows.sort_by_key(|row| row.order.get());
|
|
Ok(rows)
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
root: &ProjectPath,
|
|
sprint: &Sprint,
|
|
expected_version: SprintVersion,
|
|
) -> Result<(), SprintStoreError> {
|
|
sprint
|
|
.validate()
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
|
let current = load_sprint(root, sprint.id).await?;
|
|
if current.version != expected_version {
|
|
return Err(SprintStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
save_sprint(root, sprint).await?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
root: &ProjectPath,
|
|
sprint_id: SprintId,
|
|
) -> Result<(), SprintStoreError> {
|
|
if !tokio::fs::try_exists(sprint_path(root, sprint_id))
|
|
.await
|
|
.map_err(io_error)?
|
|
{
|
|
return Err(SprintStoreError::NotFound);
|
|
}
|
|
tokio::fs::remove_dir_all(sprint_dir(root, sprint_id))
|
|
.await
|
|
.map_err(io_error)?;
|
|
rebuild_index(root).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn render_sprint_doc(sprint: &Sprint) -> String {
|
|
format!(
|
|
"---\n\
|
|
id: {}\n\
|
|
order: {}\n\
|
|
name: {}\n\
|
|
status: {}\n\
|
|
createdBy: {}\n\
|
|
updatedBy: {}\n\
|
|
createdAt: {}\n\
|
|
updatedAt: {}\n\
|
|
version: {}\n\
|
|
---\n",
|
|
json_string(&sprint.id.to_string()),
|
|
sprint.order.get(),
|
|
json_string(&sprint.name),
|
|
json_value(&sprint.status),
|
|
json_value(&sprint.created_by),
|
|
json_value(&sprint.updated_by),
|
|
sprint.created_at,
|
|
sprint.updated_at,
|
|
sprint.version.get(),
|
|
)
|
|
}
|
|
|
|
fn parse_sprint_doc(text: &str) -> Result<Sprint, SprintStoreError> {
|
|
let (fm, _body) = split_frontmatter(text)?;
|
|
let map = parse_frontmatter_map(fm)?;
|
|
let id_raw: String = parse_json_field(&map, "id")?;
|
|
let id = SprintId::from_uuid(
|
|
uuid::Uuid::parse_str(&id_raw).map_err(|err| SprintStoreError::Invalid(err.to_string()))?,
|
|
);
|
|
let order = SprintOrder::new(parse_plain_u32(&map, "order")?)
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
|
let name: String = parse_json_field(&map, "name")?;
|
|
let status: SprintStatus = parse_json_field(&map, "status")?;
|
|
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
|
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
|
let created_at = parse_plain_u64(&map, "createdAt")?;
|
|
let updated_at = parse_plain_u64(&map, "updatedAt")?;
|
|
let version = SprintVersion::new(parse_plain_u64(&map, "version")?)
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
|
Sprint::rehydrate(Sprint {
|
|
id,
|
|
order,
|
|
name,
|
|
status,
|
|
created_by,
|
|
updated_by,
|
|
created_at,
|
|
updated_at,
|
|
version,
|
|
})
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn split_frontmatter(text: &str) -> Result<(&str, &str), SprintStoreError> {
|
|
let rest = text
|
|
.strip_prefix("---\n")
|
|
.or_else(|| text.strip_prefix("---\r\n"))
|
|
.ok_or_else(|| SprintStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
|
|
let mut offset = 0;
|
|
for line in rest.split_inclusive('\n') {
|
|
if line.trim_end_matches(['\r', '\n']) == "---" {
|
|
let body_start = offset + line.len();
|
|
return Ok((&rest[..offset], &rest[body_start..]));
|
|
}
|
|
offset += line.len();
|
|
}
|
|
Err(SprintStoreError::Invalid(
|
|
"missing closing frontmatter fence".to_owned(),
|
|
))
|
|
}
|
|
|
|
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, SprintStoreError> {
|
|
let mut map = BTreeMap::new();
|
|
for line in block.lines().filter(|line| !line.trim().is_empty()) {
|
|
let (key, value) = line
|
|
.split_once(':')
|
|
.ok_or_else(|| SprintStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
|
|
map.insert(key.trim().to_owned(), value.trim().to_owned());
|
|
}
|
|
Ok(map)
|
|
}
|
|
|
|
fn parse_plain_u32(map: &BTreeMap<String, String>, key: &str) -> Result<u32, SprintStoreError> {
|
|
map.get(key)
|
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
|
.parse::<u32>()
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, SprintStoreError> {
|
|
map.get(key)
|
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
|
.parse::<u64>()
|
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn parse_json_field<T: serde::de::DeserializeOwned>(
|
|
map: &BTreeMap<String, String>,
|
|
key: &str,
|
|
) -> Result<T, SprintStoreError> {
|
|
let value = map
|
|
.get(key)
|
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
|
|
serde_json::from_str(value).map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
|
}
|
|
|
|
fn json_value<T: Serialize>(value: &T) -> String {
|
|
serde_json::to_string(value).expect("serializing sprint frontmatter cannot fail")
|
|
}
|
|
|
|
fn json_string(value: &str) -> String {
|
|
serde_json::to_string(value).expect("serializing sprint string cannot fail")
|
|
}
|