Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/plans/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,21 @@
- 보류 (archive): **13개**
- IA/검토 (archive): **31개**
- **합계**: 168개

---

> 아래 영역은 tunaFlow 가 DB `plans` 테이블 기준으로 자동 생성/갱신합니다
> (`regenerate_plans_index` command). 위쪽 수동 설명·통계는 보존됩니다
> (`docsPlansOrganizationPlan_2026-05-29`, INV-DPO-4).

<!-- AUTO-INDEX-START -->
> 이 영역은 tunaFlow 가 자동 생성합니다 (DB plans 테이블 기준). 직접 편집하지 마세요.

### 🟢 진행 중 (DB 기준)

_앱에서 `regenerate_plans_index` 실행 시 현재 프로젝트의 DB plan 으로 채워집니다._

### 📦 아카이브 요약 (DB 기준)

_앱 실행 시 완료/중단 카운트가 채워집니다._
<!-- AUTO-INDEX-END -->
232 changes: 232 additions & 0 deletions src-tauri/src/commands/plans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,136 @@ pub fn generate_result_report(
Ok(file_path.to_string_lossy().to_string())
}

// ─── Plans index auto-generation (T3, docsPlansOrganizationPlan_2026-05-29) ───
//
// docs/plans/index.md 의 자동 영역만 마커 (`<!-- AUTO-INDEX-START/END -->`)
// 사이에서 갱신. 마커 밖 수동 설명 (navigationModel 구조 안내) 보존
// (INV-DPO-4). 경로 불변 — 파일 이동 없음 (Phase 1).

pub const PLANS_INDEX_START_MARKER: &str = "<!-- AUTO-INDEX-START -->";
pub const PLANS_INDEX_END_MARKER: &str = "<!-- AUTO-INDEX-END -->";

/// Build the auto-generated index block (active plan table + archive summary).
/// `plans` 는 list_plans_by_project 결과 (created_at ASC). updated_at 은 ms epoch.
fn build_plans_index_block(plans: &[Plan]) -> String {
let mut active: Vec<&Plan> = plans
.iter()
.filter(|p| p.status == "draft" || p.status == "active")
.collect();
// 최근 갱신 순 (updated_at DESC).
active.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));

let done = plans.iter().filter(|p| p.status == "done").count();
let abandoned = plans.iter().filter(|p| p.status == "abandoned").count();

let mut s = String::new();
s.push_str(PLANS_INDEX_START_MARKER);
s.push('\n');
s.push_str("> 이 영역은 tunaFlow 가 자동 생성합니다 (DB plans 테이블 기준). 직접 편집하지 마세요.\n\n");

s.push_str("### 🟢 진행 중 (DB 기준)\n\n");
if active.is_empty() {
s.push_str("_진행 중 plan 없음._\n\n");
} else {
s.push_str("| 문서 | 상태 | 갱신 |\n");
s.push_str("|------|------|------|\n");
for p in &active {
let slug = p.slug.clone().unwrap_or_else(|| slugify(&p.title));
let date = fmt_index_date(p.updated_at);
let title = p.title.replace('|', "\\|");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

플랜 제목(p.title)에 대괄호([ 또는 ])가 포함되어 있을 경우, 생성되는 마크다운 링크([제목](./경로.md))의 문법이 깨질 수 있습니다. 대괄호도 함께 이스케이프 처리해 주는 것이 안전합니다.

Suggested change
let title = p.title.replace('|', "\\|");
let title = p.title.replace('|', "\\|").replace('[', "\\[").replace(']', "\\]");

s.push_str(&format!(
"| [{}](./{}.md) | {} | {} |\n",
title, slug, p.status, date
));
}
s.push('\n');
}

s.push_str("### 📦 아카이브 요약 (DB 기준)\n\n");
s.push_str(&format!("- 완료(done): **{}개**\n", done));
s.push_str(&format!("- 중단(abandoned): **{}개**\n", abandoned));
s.push_str("- 물리 아카이브 경로: `docs/archive/plans/{completed,deferred,misc,superseded}/`\n\n");

s.push_str(PLANS_INDEX_END_MARKER);
s
}

/// updated_at(ms epoch) → "YYYY-MM-DD". chrono 는 seconds 기대 → /1000.
fn fmt_index_date(updated_at_ms: i64) -> String {
chrono::DateTime::from_timestamp(updated_at_ms / 1000, 0)
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| updated_at_ms.to_string())
}

/// Merge `block` (포함: START/END 마커) into `existing` index.md content.
/// - 마커 쌍이 있으면 그 사이를 교체 (마커 밖 수동 영역 보존).
/// - 마커가 없으면 기존 내용 끝에 block 을 append (수동 영역 전체 보존).
/// 반환값은 새 전체 파일 내용.
fn merge_index_block(existing: &str, block: &str) -> String {
if let (Some(start), Some(end)) = (
existing.find(PLANS_INDEX_START_MARKER),
existing.find(PLANS_INDEX_END_MARKER),
) {
if end > start {
let end_full = end + PLANS_INDEX_END_MARKER.len();
let mut out = String::new();
out.push_str(&existing[..start]);
out.push_str(block);
out.push_str(&existing[end_full..]);
return out;
}
}
// 마커 없음 → append. 기존 끝 개행 정규화 후 block 삽입.
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(block);
out.push('\n');
out
}

/// Regenerate the auto section of docs/plans/index.md from the DB plans table.
/// Marker-based partial update — manual description outside the markers is
/// preserved (INV-DPO-4). Returns the index.md path.
#[tauri::command]
pub fn regenerate_plans_index(
project_key: String,
project_path: String,
state: State<DbState>,
) -> Result<String, AppError> {
let plans = {
let conn = state.read.lock().map_err(|_| AppError::Lock)?;
let sql = format!(
"SELECT {} FROM plans p JOIN conversations c ON c.id = p.conversation_id
WHERE c.project_key = ?1 ORDER BY p.created_at ASC",
PLAN_COLS
.split(", ")
.map(|c| format!("p.{}", c))
.collect::<Vec<_>>()
.join(", ")
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map([&project_key], map_plan)?
.collect::<Result<Vec<_>, _>>()?;
rows
}; // lock released

let block = build_plans_index_block(&plans);

let dir = Path::new(&project_path).join("docs").join("plans");
std::fs::create_dir_all(&dir)
.map_err(|e| AppError::Agent(format!("Failed to create dir: {}", e)))?;
let file_path = dir.join("index.md");

let existing = std::fs::read_to_string(&file_path).unwrap_or_default();
let merged = merge_index_block(&existing, &block);

atomic_write_md(&file_path, &merged)?;
Ok(file_path.to_string_lossy().to_string())
}

fn build_plan_markdown(plan: &Plan, subtasks: &[PlanSubtask], events: &[PlanEvent]) -> String {
let mut md = String::new();

Expand Down Expand Up @@ -1372,4 +1502,106 @@ mod tests {

let _ = std::fs::remove_dir_all(&dir);
}

// ─── T3 — plans index auto-generation (marker merge + block build) ────────

fn mk_plan(slug: &str, title: &str, status: &str, updated_at: i64) -> Plan {
Plan {
id: format!("id-{}", slug),
conversation_id: "c".into(),
branch_id: None,
title: title.into(),
description: None,
expected_outcome: None,
status: status.into(),
phase: "drafting".into(),
architect_engine: None,
developer_engine: None,
reviewer_engines: None,
implementation_branch_id: None,
review_branch_id: None,
slug: Some(slug.into()),
revision: 0,
version_major: 1,
version_minor: 0,
created_at: 0,
updated_at,
}
}

#[test]
fn index_block_lists_active_and_summarizes_archive() {
let plans = vec![
mk_plan("alpha", "Alpha Plan", "active", 2_000),
mk_plan("beta", "Beta Plan", "draft", 3_000),
mk_plan("gamma", "Gamma Plan", "done", 1_000),
mk_plan("delta", "Delta Plan", "abandoned", 500),
];
let block = build_plans_index_block(&plans);

// Markers present.
assert!(block.starts_with(PLANS_INDEX_START_MARKER));
assert!(block.trim_end().ends_with(PLANS_INDEX_END_MARKER));

// Active table contains active + draft (slug links), not done/abandoned.
assert!(block.contains("[Alpha Plan](./alpha.md)"));
assert!(block.contains("[Beta Plan](./beta.md)"));
assert!(!block.contains("[Gamma Plan](./gamma.md)"));
assert!(!block.contains("[Delta Plan](./delta.md)"));

// draft (updated 3000) sorted before active (updated 2000) — DESC by updated_at.
let beta = block.find("Beta Plan").unwrap();
let alpha = block.find("Alpha Plan").unwrap();
assert!(beta < alpha, "draft (newer) must appear before active (older)");

// Archive summary counts.
assert!(block.contains("완료(done): **1개**"));
assert!(block.contains("중단(abandoned): **1개**"));
}

#[test]
fn index_block_empty_active_renders_placeholder() {
let plans = vec![mk_plan("g", "G", "done", 1)];
let block = build_plans_index_block(&plans);
assert!(block.contains("진행 중 plan 없음"));
assert!(block.contains("완료(done): **1개**"));
}

#[test]
fn merge_replaces_only_marker_region_preserving_manual() {
let existing = "# Plans\n\n수동 설명 위.\n\n<!-- AUTO-INDEX-START -->\nOLD AUTO\n<!-- AUTO-INDEX-END -->\n\n수동 설명 아래.\n";
let block = "<!-- AUTO-INDEX-START -->\nNEW AUTO\n<!-- AUTO-INDEX-END -->";
let merged = merge_index_block(existing, block);

// Manual text on both sides preserved.
assert!(merged.contains("수동 설명 위."));
assert!(merged.contains("수동 설명 아래."));
// Auto region replaced.
assert!(merged.contains("NEW AUTO"));
assert!(!merged.contains("OLD AUTO"));
// Exactly one marker pair (no duplication).
assert_eq!(merged.matches(PLANS_INDEX_START_MARKER).count(), 1);
assert_eq!(merged.matches(PLANS_INDEX_END_MARKER).count(), 1);
}

#[test]
fn merge_appends_block_when_no_markers() {
let existing = "# Plans — 진행 현황\n\n수동 인덱스 (마커 없음).\n";
let block = "<!-- AUTO-INDEX-START -->\nAUTO\n<!-- AUTO-INDEX-END -->";
let merged = merge_index_block(existing, block);

// Original manual content fully preserved.
assert!(merged.contains("# Plans — 진행 현황"));
assert!(merged.contains("수동 인덱스 (마커 없음)."));
// Block appended once.
assert!(merged.contains("AUTO"));
assert_eq!(merged.matches(PLANS_INDEX_START_MARKER).count(), 1);
}

#[test]
fn merge_into_empty_creates_block() {
let merged = merge_index_block("", "<!-- AUTO-INDEX-START -->\nX\n<!-- AUTO-INDEX-END -->");
assert!(merged.contains(PLANS_INDEX_START_MARKER));
assert!(merged.contains("X"));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ pub fn run() {
commands::plans::generate_plan_document,
commands::plans::generate_review_report,
commands::plans::generate_result_report,
commands::plans::regenerate_plans_index,
// Failure Lessons
commands::failure_lessons::create_failure_lesson,
commands::failure_lessons::create_failure_lessons_batch,
Expand Down
2 changes: 1 addition & 1 deletion src/components/tunaflow/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ export function Sidebar() {
{/* Tab content — px-2 to align with top area (mx-1 + px-2 = 12px from edge) */}
<div className="flex-1 overflow-y-auto px-2 pb-2 min-h-0">
{refTab === "docs" && (
<DocsSection projectPath={currentProject?.path} />
<DocsSection projectPath={currentProject?.path} projectKey={selectedProjectKey} />
)}
{refTab === "archive" && (
archivedBranches.length === 0 ? (
Expand Down
Loading