use std::fs;
use std::path::Path;
use chrono::Utc;
use crate::error_bridge::IntoCoreResult;
use crate::errors::{CoreError, CoreResult};
use crate::module_repository::FsModuleRepository;
use ito_common::fs::StdFs;
use ito_common::id::parse_change_id;
use ito_common::paths;
#[path = "archive_specs.rs"]
mod archive_specs;
pub(crate) fn reconcile_spec_markdown(
base: Option<&str>,
delta: &str,
) -> CoreResult<Option<String>> {
archive_specs::reconcile_spec(base, delta)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStatus {
NoTasks,
AllComplete,
HasIncomplete {
pending: usize,
total: usize,
},
}
pub fn check_task_completion(contents: &str) -> TaskStatus {
let mut total = 0usize;
let mut pending = 0usize;
for raw in contents.lines() {
let line = raw.trim();
if line.starts_with("- [ ]") || line.starts_with("* [ ]") {
total += 1;
pending += 1;
continue;
}
if line.starts_with("- [~]")
|| line.starts_with("- [>]")
|| line.starts_with("* [~]")
|| line.starts_with("* [>]")
{
total += 1;
pending += 1;
continue;
}
if line.starts_with("- [x]")
|| line.starts_with("- [X]")
|| line.starts_with("* [x]")
|| line.starts_with("* [X]")
{
total += 1;
continue;
}
if line.starts_with("- **Status**:") || line.contains("**Status**:") {
if line.contains("[ ]") {
total += 1;
pending += 1;
continue;
}
if line.contains("[x]") || line.contains("[X]") {
total += 1;
continue;
}
}
}
if total == 0 {
return TaskStatus::NoTasks;
}
if pending == 0 {
return TaskStatus::AllComplete;
}
TaskStatus::HasIncomplete { pending, total }
}
pub fn list_available_changes(ito_path: &Path) -> CoreResult<Vec<String>> {
let fs = StdFs;
ito_domain::discovery::list_change_dir_names(&fs, ito_path).into_core()
}
pub fn change_exists(ito_path: &Path, change_name: &str) -> bool {
if change_name.trim().is_empty() {
return false;
}
let proposal = paths::change_dir(ito_path, change_name).join("proposal.md");
proposal.exists()
}
pub fn generate_archive_name(change_name: &str) -> String {
let date = Utc::now().format("%Y-%m-%d").to_string();
format!("{date}-{change_name}")
}
pub fn archive_exists(ito_path: &Path, archive_name: &str) -> bool {
let dir = paths::changes_archive_dir(ito_path).join(archive_name);
dir.exists()
}
pub fn discover_change_specs(ito_path: &Path, change_name: &str) -> CoreResult<Vec<String>> {
let mut out: Vec<String> = Vec::new();
let specs_dir = paths::change_specs_dir(ito_path, change_name);
if !specs_dir.exists() {
return Ok(out);
}
let entries = fs::read_dir(&specs_dir)
.map_err(|e| CoreError::io(format!("reading {}", specs_dir.display()), e))?;
for entry in entries {
let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if !is_dir {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.trim().is_empty() {
continue;
}
let spec_md = entry.path().join("spec.md");
if !spec_md.exists() {
continue;
}
out.push(name);
}
out.sort();
Ok(out)
}
pub fn categorize_specs(ito_path: &Path, spec_names: &[String]) -> (Vec<String>, Vec<String>) {
let mut new_specs: Vec<String> = Vec::new();
let mut existing_specs: Vec<String> = Vec::new();
for spec in spec_names {
let dst = paths::spec_markdown_path(ito_path, spec);
if dst.exists() {
existing_specs.push(spec.clone());
} else {
new_specs.push(spec.clone());
}
}
(new_specs, existing_specs)
}
pub fn copy_specs_to_main(
ito_path: &Path,
change_name: &str,
spec_names: &[String],
) -> CoreResult<Vec<String>> {
let mut pending = Vec::new();
for spec in spec_names {
let src = paths::change_specs_dir(ito_path, change_name)
.join(spec)
.join("spec.md");
if !src.exists() {
continue;
}
let delta = ito_common::io::read_to_string_std(&src)
.map_err(|e| CoreError::io(format!("reading spec {}", src.display()), e))?;
let dst_dir = paths::specs_dir(ito_path).join(spec);
let dst = dst_dir.join("spec.md");
let base = if dst.exists() {
Some(
ito_common::io::read_to_string_std(&dst)
.map_err(|e| CoreError::io(format!("reading spec {}", dst.display()), e))?,
)
} else {
None
};
let reconciled = archive_specs::reconcile_spec(base.as_deref(), &delta)?;
pending.push((spec.clone(), dst_dir, dst, reconciled));
}
let mut updated = Vec::with_capacity(pending.len());
for (spec, dst_dir, dst, reconciled) in pending {
match reconciled {
Some(markdown) => {
ito_common::io::create_dir_all_std(&dst_dir).map_err(|e| {
CoreError::io(format!("creating spec dir {}", dst_dir.display()), e)
})?;
ito_common::io::write_std(&dst, markdown)
.map_err(|e| CoreError::io(format!("writing spec {}", dst.display()), e))?;
}
None => {
if dst.exists() {
std::fs::remove_file(&dst).map_err(|e| {
CoreError::io(format!("removing retired spec {}", dst.display()), e)
})?;
}
if dst_dir.exists() {
let mut entries = std::fs::read_dir(&dst_dir).map_err(|e| {
CoreError::io(format!("reading spec dir {}", dst_dir.display()), e)
})?;
if entries
.next()
.transpose()
.map_err(|e| {
CoreError::io(format!("reading spec dir {}", dst_dir.display()), e)
})?
.is_none()
{
std::fs::remove_dir(&dst_dir).map_err(|e| {
CoreError::io(
format!("removing retired spec dir {}", dst_dir.display()),
e,
)
})?;
}
}
}
}
updated.push(spec);
}
Ok(updated)
}
pub fn mark_change_complete_in_module_markdown(
ito_path: &Path,
change_name: &str,
) -> CoreResult<()> {
let Ok(parsed) = parse_change_id(change_name) else {
return Ok(());
};
let module_id = parsed.module_id;
let module_repo = FsModuleRepository::new(ito_path);
let Some(resolved) =
crate::validate::resolve_module(&module_repo, ito_path, module_id.as_str())?
else {
return Ok(());
};
let md = ito_common::io::read_to_string_std(&resolved.module_md)
.map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
let mut out = String::new();
for line in md.lines() {
if line.contains(change_name) {
out.push_str(
&line
.replacen("- [ ]", "- [x]", 1)
.replacen("* [ ]", "* [x]", 1),
);
out.push('\n');
continue;
}
out.push_str(line);
out.push('\n');
}
ito_common::io::write_std(&resolved.module_md, out)
.map_err(|e| CoreError::io(format!("writing {}", resolved.module_md.display()), e))?;
Ok(())
}
pub fn move_to_archive(ito_path: &Path, change_name: &str, archive_name: &str) -> CoreResult<()> {
let change_dir = paths::change_dir(ito_path, change_name);
if !change_dir.exists() {
return Err(CoreError::not_found(format!(
"Change '{change_name}' not found"
)));
}
let archive_root = paths::changes_archive_dir(ito_path);
ito_common::io::create_dir_all_std(&archive_root)
.map_err(|e| CoreError::io("creating archive directory", e))?;
let dst = archive_root.join(archive_name);
if dst.exists() {
return Err(CoreError::validation(format!(
"Archive target already exists: {}",
dst.display()
)));
}
fs::rename(&change_dir, &dst).map_err(|e| CoreError::io("moving change to archive", e))?;
Ok(())
}