Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:
- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs
Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
556 lines
17 KiB
Rust
556 lines
17 KiB
Rust
// synops-feature-status — Sjekk feature-status.
|
|
//
|
|
// Samler informasjon om en feature fra fire kilder:
|
|
// 1. Spec-fil på disk (docs/features/ eller docs/concepts/)
|
|
// 2. Oppgavestatus fra tasks.md
|
|
// 3. Nylige git-commits som berører featuren
|
|
// 4. Ubesvart feedback fra databasen (kommunikasjonsnoder med discusses-edge)
|
|
//
|
|
// Output: markdown-formatert sammendrag til stdout.
|
|
//
|
|
// Miljøvariabler:
|
|
// DATABASE_URL — PostgreSQL-tilkobling (valgfri — feedback hoppes over uten)
|
|
//
|
|
// Ref: docs/retninger/unix_filosofi.md, docs/features/feature_feedback.md
|
|
|
|
use clap::Parser;
|
|
use regex::Regex;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{self, Command};
|
|
|
|
/// Sjekk feature-status: spec, oppgaver, commits og feedback.
|
|
#[derive(Parser)]
|
|
#[command(name = "synops-feature-status", about = "Sjekk feature-status")]
|
|
struct Cli {
|
|
/// Feature-nøkkel (f.eks. "lydstudio", "chat", "publisering")
|
|
feature_key: String,
|
|
|
|
/// Antall commits å vise (default: 10)
|
|
#[arg(long, default_value_t = 10)]
|
|
commits: usize,
|
|
|
|
/// Repo-rot (default: /home/vegard/synops)
|
|
#[arg(long, default_value = "/home/vegard/synops")]
|
|
repo: String,
|
|
}
|
|
|
|
fn main() {
|
|
synops_common::logging::init("synops_feature_status");
|
|
let cli = Cli::parse();
|
|
let repo = PathBuf::from(&cli.repo);
|
|
|
|
if !repo.join("tasks.md").exists() {
|
|
eprintln!("Feil: Fant ikke tasks.md i {}", repo.display());
|
|
process::exit(1);
|
|
}
|
|
|
|
let key = &cli.feature_key;
|
|
|
|
println!("# Feature-status: {key}\n");
|
|
|
|
// 1. Spec-sammendrag
|
|
print_spec_summary(&repo, key);
|
|
|
|
// 2. Oppgavestatus
|
|
print_task_status(&repo, key);
|
|
|
|
// 3. Nylige commits
|
|
print_recent_commits(&repo, key, cli.commits);
|
|
|
|
// 4. Ubesvart feedback (krever DATABASE_URL)
|
|
run_feedback(key);
|
|
}
|
|
|
|
// --- Spec-sammendrag ---
|
|
|
|
fn find_spec_file(repo: &Path, key: &str) -> Option<PathBuf> {
|
|
let key_lower = key.to_lowercase();
|
|
let underscore = key_lower.replace('-', "_");
|
|
let hyphen = key_lower.replace('_', "-");
|
|
|
|
let candidates = [
|
|
repo.join(format!("docs/features/{key_lower}.md")),
|
|
repo.join(format!("docs/concepts/{key_lower}.md")),
|
|
repo.join(format!("docs/features/{underscore}.md")),
|
|
repo.join(format!("docs/concepts/{underscore}.md")),
|
|
repo.join(format!("docs/features/{hyphen}.md")),
|
|
repo.join(format!("docs/concepts/{hyphen}.md")),
|
|
];
|
|
|
|
for path in &candidates {
|
|
if path.exists() {
|
|
return Some(path.clone());
|
|
}
|
|
}
|
|
|
|
// Fuzzy: søk i begge mapper etter filer som inneholder nøkkelen
|
|
for dir in &["docs/features", "docs/concepts"] {
|
|
let dir_path = repo.join(dir);
|
|
if let Ok(entries) = std::fs::read_dir(&dir_path) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name().to_string_lossy().to_lowercase();
|
|
if name.ends_with(".md") && name.contains(&key_lower) {
|
|
return Some(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn extract_summary(content: &str) -> String {
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
let mut summary_parts: Vec<String> = Vec::new();
|
|
|
|
// Hent tittelen (første # heading)
|
|
for line in &lines {
|
|
if line.starts_with("# ") {
|
|
summary_parts.push(line.to_string());
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Hent første avsnitt etter tittelen (eller etter frontmatter)
|
|
let mut in_frontmatter = false;
|
|
let mut past_title = false;
|
|
let mut paragraph = Vec::new();
|
|
|
|
for line in &lines {
|
|
if line.starts_with("---") {
|
|
in_frontmatter = !in_frontmatter;
|
|
continue;
|
|
}
|
|
if in_frontmatter {
|
|
continue;
|
|
}
|
|
if line.starts_with("# ") {
|
|
past_title = true;
|
|
continue;
|
|
}
|
|
if !past_title {
|
|
continue;
|
|
}
|
|
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
if !paragraph.is_empty() {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if trimmed.starts_with('#') {
|
|
if !paragraph.is_empty() {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
paragraph.push(trimmed.to_string());
|
|
}
|
|
|
|
if !paragraph.is_empty() {
|
|
summary_parts.push(paragraph.join(" "));
|
|
}
|
|
|
|
// Hent ## headings som oversikt
|
|
let headings: Vec<&str> = lines
|
|
.iter()
|
|
.filter(|l| l.starts_with("## "))
|
|
.copied()
|
|
.collect();
|
|
|
|
if !headings.is_empty() {
|
|
summary_parts.push(String::new());
|
|
summary_parts.push("**Seksjoner:**".to_string());
|
|
for h in &headings {
|
|
summary_parts.push(format!("- {}", h.trim_start_matches("## ")));
|
|
}
|
|
}
|
|
|
|
summary_parts.join("\n")
|
|
}
|
|
|
|
fn print_spec_summary(repo: &Path, key: &str) {
|
|
println!("## Spec\n");
|
|
|
|
match find_spec_file(repo, key) {
|
|
Some(path) => {
|
|
let rel_path = path
|
|
.strip_prefix(repo)
|
|
.unwrap_or(&path)
|
|
.display()
|
|
.to_string();
|
|
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(content) => {
|
|
println!("Kilde: `{rel_path}`\n");
|
|
let summary = extract_summary(&content);
|
|
if summary.is_empty() {
|
|
println!("_(Tom spec-fil)_\n");
|
|
} else {
|
|
println!("{summary}\n");
|
|
}
|
|
}
|
|
Err(e) => println!("Kunne ikke lese {rel_path}: {e}\n"),
|
|
}
|
|
}
|
|
None => {
|
|
println!("Ingen spec-fil funnet for «{key}».");
|
|
println!("Søkte i: docs/features/, docs/concepts/\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Oppgavestatus ---
|
|
|
|
struct TaskInfo {
|
|
phase: u32,
|
|
phase_title: String,
|
|
id: String,
|
|
title: String,
|
|
status: String,
|
|
details: Vec<String>,
|
|
}
|
|
|
|
fn print_task_status(repo: &Path, key: &str) {
|
|
println!("## Oppgaver\n");
|
|
|
|
let tasks_path = repo.join("tasks.md");
|
|
let content = match std::fs::read_to_string(&tasks_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
println!("Kunne ikke lese tasks.md: {e}\n");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let key_lower = key.to_lowercase();
|
|
let search_terms: Vec<String> = vec![
|
|
key_lower.clone(),
|
|
key_lower.replace('-', "_"),
|
|
key_lower.replace('_', "-"),
|
|
key_lower.replace('_', " "),
|
|
key_lower.replace('-', " "),
|
|
];
|
|
|
|
let phase_re = Regex::new(r"^## Fase (\d+):\s*(.+)$").unwrap();
|
|
let task_re = Regex::new(r"^- \[(.)\] (\d+\.\d+)\s+(.+)$").unwrap();
|
|
let detail_re = Regex::new(r"^ > (.+)$").unwrap();
|
|
|
|
let mut all_tasks: Vec<TaskInfo> = Vec::new();
|
|
let mut current_phase: Option<(u32, String)> = None;
|
|
|
|
for line in content.lines() {
|
|
if let Some(caps) = phase_re.captures(line) {
|
|
let num: u32 = caps[1].parse().unwrap_or(0);
|
|
let title = caps[2].trim().to_string();
|
|
current_phase = Some((num, title));
|
|
continue;
|
|
}
|
|
|
|
if let Some(caps) = task_re.captures(line) {
|
|
let status_char = &caps[1];
|
|
let id = caps[2].to_string();
|
|
let title = caps[3].to_string();
|
|
|
|
let status = match status_char {
|
|
" " => "[ ]",
|
|
"~" => "[~]",
|
|
"x" => "[x]",
|
|
"?" => "[?]",
|
|
"!" => "[!]",
|
|
_ => "[?]",
|
|
}
|
|
.to_string();
|
|
|
|
let (phase, phase_title) = current_phase
|
|
.clone()
|
|
.unwrap_or((0, "Ukjent".to_string()));
|
|
|
|
all_tasks.push(TaskInfo {
|
|
phase,
|
|
phase_title,
|
|
id,
|
|
title,
|
|
status,
|
|
details: Vec::new(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if let Some(caps) = detail_re.captures(line) {
|
|
if let Some(task) = all_tasks.last_mut() {
|
|
task.details.push(caps[1].to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finn oppgaver som matcher feature-nøkkelen
|
|
let mut matching: Vec<&TaskInfo> = all_tasks
|
|
.iter()
|
|
.filter(|t| {
|
|
let title_lower = t.title.to_lowercase();
|
|
let phase_lower = t.phase_title.to_lowercase();
|
|
search_terms
|
|
.iter()
|
|
.any(|term| title_lower.contains(term) || phase_lower.contains(term))
|
|
})
|
|
.collect();
|
|
|
|
// Også prøv synops-<key> hvis ingenting funnet
|
|
if matching.is_empty() {
|
|
let tool_name = format!("synops-{}", key_lower);
|
|
matching = all_tasks
|
|
.iter()
|
|
.filter(|t| t.title.to_lowercase().contains(&tool_name))
|
|
.collect();
|
|
}
|
|
|
|
if matching.is_empty() {
|
|
println!("Ingen oppgaver funnet for «{key}».\n");
|
|
return;
|
|
}
|
|
|
|
let total = matching.len();
|
|
let done = matching.iter().filter(|t| t.status == "[x]").count();
|
|
|
|
println!("Funnet {total} relaterte oppgaver ({done}/{total} ferdig):\n");
|
|
|
|
let mut current_ph: Option<u32> = None;
|
|
for task in &matching {
|
|
if current_ph != Some(task.phase) {
|
|
current_ph = Some(task.phase);
|
|
println!("### Fase {}: {}\n", task.phase, task.phase_title);
|
|
}
|
|
println!("- {} {} {}", task.status, task.id, task.title);
|
|
for detail in &task.details {
|
|
println!(" > {detail}");
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
|
|
// --- Git-commits ---
|
|
|
|
fn print_recent_commits(repo: &Path, key: &str, max: usize) {
|
|
println!("## Nylige commits\n");
|
|
|
|
let key_lower = key.to_lowercase();
|
|
let search_patterns: Vec<String> = vec![
|
|
key_lower.clone(),
|
|
key_lower.replace('-', "_"),
|
|
key_lower.replace('_', "-"),
|
|
format!("synops-{}", key_lower),
|
|
];
|
|
|
|
let file_patterns: Vec<String> = vec![
|
|
format!("docs/features/{}*", key_lower),
|
|
format!("docs/features/{}*", key_lower.replace('-', "_")),
|
|
format!("docs/concepts/{}*", key_lower),
|
|
format!("docs/concepts/{}*", key_lower.replace('-', "_")),
|
|
format!("tools/synops-{}*", key_lower),
|
|
];
|
|
|
|
let mut commits: Vec<(String, String, String)> = Vec::new();
|
|
let mut seen_hashes: std::collections::HashSet<String> =
|
|
std::collections::HashSet::new();
|
|
|
|
// Commits fra filer
|
|
for pattern in &file_patterns {
|
|
if let Ok(output) = Command::new("git")
|
|
.args([
|
|
"log",
|
|
"--oneline",
|
|
"--format=%h|%ai|%s",
|
|
&format!("-{max}"),
|
|
"--",
|
|
pattern,
|
|
])
|
|
.current_dir(repo)
|
|
.output()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
parse_commit_lines(&stdout, &mut commits, &mut seen_hashes);
|
|
}
|
|
}
|
|
|
|
// Commits fra meldinger (--grep)
|
|
for term in &search_patterns {
|
|
if let Ok(output) = Command::new("git")
|
|
.args([
|
|
"log",
|
|
"--oneline",
|
|
"--format=%h|%ai|%s",
|
|
&format!("-{max}"),
|
|
"-i",
|
|
"--grep",
|
|
term,
|
|
])
|
|
.current_dir(repo)
|
|
.output()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
parse_commit_lines(&stdout, &mut commits, &mut seen_hashes);
|
|
}
|
|
}
|
|
|
|
// Sorter etter dato (nyeste først)
|
|
commits.sort_by(|a, b| b.1.cmp(&a.1));
|
|
commits.truncate(max);
|
|
|
|
if commits.is_empty() {
|
|
println!("Ingen commits funnet for «{key}».\n");
|
|
return;
|
|
}
|
|
|
|
println!("{} relevante commits:\n", commits.len());
|
|
for (hash, date, msg) in &commits {
|
|
let short_date = if date.len() >= 10 { &date[..10] } else { date };
|
|
println!("- `{hash}` ({short_date}) {msg}");
|
|
}
|
|
println!();
|
|
}
|
|
|
|
fn parse_commit_lines(
|
|
stdout: &str,
|
|
commits: &mut Vec<(String, String, String)>,
|
|
seen: &mut std::collections::HashSet<String>,
|
|
) {
|
|
for line in stdout.lines() {
|
|
let parts: Vec<&str> = line.splitn(3, '|').collect();
|
|
if parts.len() == 3 && seen.insert(parts[0].to_string()) {
|
|
commits.push((
|
|
parts[0].to_string(),
|
|
parts[1].to_string(),
|
|
parts[2].to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Feedback fra database ---
|
|
|
|
fn run_feedback(key: &str) {
|
|
// Sikre at DATABASE_URL er satt — prøv maskinrommet.env som fallback
|
|
if std::env::var("DATABASE_URL").is_err() {
|
|
if let Ok(content) = std::fs::read_to_string("/tmp/maskinrommet.env") {
|
|
for line in content.lines() {
|
|
if let Some(val) = line.strip_prefix("DATABASE_URL=") {
|
|
// SAFETY: kalles tidlig i main, før noen tråder er startet.
|
|
unsafe { std::env::set_var("DATABASE_URL", val); }
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(print_feedback(key));
|
|
}
|
|
|
|
async fn print_feedback(key: &str) {
|
|
println!("## Feedback\n");
|
|
|
|
let db = match synops_common::db::connect().await {
|
|
Ok(pool) => pool,
|
|
Err(e) => {
|
|
eprintln!("{e}");
|
|
println!("_(DATABASE_URL ikke satt eller databasefeil — feedback hoppes over)_\n");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Finn spec-noder med matching feature_key
|
|
let spec_nodes: Vec<(uuid::Uuid, Option<String>)> = sqlx::query_as(
|
|
"SELECT id, title FROM nodes \
|
|
WHERE metadata->>'feature_spec' = 'true' \
|
|
AND LOWER(metadata->>'feature_key') = LOWER($1) \
|
|
LIMIT 5",
|
|
)
|
|
.bind(key)
|
|
.fetch_all(&db)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
if spec_nodes.is_empty() {
|
|
println!("Ingen spec-node funnet i databasen for «{key}».");
|
|
println!("Feature feedback-systemet er kanskje ikke satt opp for denne featuren.\n");
|
|
return;
|
|
}
|
|
|
|
for (node_id, title) in &spec_nodes {
|
|
let t = title.as_deref().unwrap_or("Uten tittel");
|
|
println!("Spec-node: `{node_id}` — {t}");
|
|
print_feedback_for_spec(&db, *node_id).await;
|
|
}
|
|
|
|
println!();
|
|
}
|
|
|
|
async fn print_feedback_for_spec(db: &sqlx::PgPool, spec_node_id: uuid::Uuid) {
|
|
// Finn feedback-chatter (kommunikasjonsnoder med discusses-edge til spec)
|
|
let feedback_chats: Vec<(uuid::Uuid, Option<String>)> = sqlx::query_as(
|
|
"SELECT n.id, n.title \
|
|
FROM nodes n \
|
|
JOIN edges e ON e.source_id = n.id \
|
|
WHERE e.target_id = $1 \
|
|
AND e.edge_type = 'discusses' \
|
|
AND n.node_kind = 'communication' \
|
|
LIMIT 10",
|
|
)
|
|
.bind(spec_node_id)
|
|
.fetch_all(db)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
if feedback_chats.is_empty() {
|
|
println!(" Ingen feedback-chatter koblet til denne spec-noden.\n");
|
|
return;
|
|
}
|
|
|
|
for (chat_id, chat_title) in &feedback_chats {
|
|
let ct = chat_title.as_deref().unwrap_or("Feedback");
|
|
println!("\n Feedback-chat: `{chat_id}` — {ct}");
|
|
|
|
// Finn ubesvarte meldinger: meldinger nyere enn siste Claude-svar
|
|
let unanswered: Vec<(uuid::Uuid, Option<String>, chrono::DateTime<chrono::Utc>)> =
|
|
sqlx::query_as(
|
|
"SELECT n.id, \
|
|
LEFT(n.content, 200) AS excerpt, \
|
|
n.created_at \
|
|
FROM nodes n \
|
|
JOIN edges e ON e.source_id = n.id \
|
|
WHERE e.target_id = $1 \
|
|
AND e.edge_type = 'belongs_to' \
|
|
AND n.created_at > COALESCE( \
|
|
(SELECT MAX(n2.created_at) \
|
|
FROM nodes n2 \
|
|
JOIN edges e2 ON e2.source_id = n2.id \
|
|
WHERE e2.target_id = $1 \
|
|
AND e2.edge_type = 'belongs_to' \
|
|
AND n2.created_by = 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid), \
|
|
'1970-01-01'::timestamptz) \
|
|
AND n.created_by != 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid \
|
|
ORDER BY n.created_at DESC \
|
|
LIMIT 20",
|
|
)
|
|
.bind(chat_id)
|
|
.fetch_all(db)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
if unanswered.is_empty() {
|
|
println!(" Ingen ubesvart feedback.");
|
|
} else {
|
|
println!(" **{} ubesvarte meldinger:**", unanswered.len());
|
|
for (msg_id, excerpt, created) in &unanswered {
|
|
let date = created.format("%Y-%m-%d %H:%M");
|
|
let text = excerpt
|
|
.as_deref()
|
|
.unwrap_or("(tomt)")
|
|
.replace('\n', " ");
|
|
println!(" - `{msg_id}` ({date}): {text}");
|
|
}
|
|
}
|
|
}
|
|
}
|