frame 0.1.6

A markdown task tracker with a terminal UI for humans and a CLI for agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
use std::fs;

use crate::cli::commands::InitArgs;
use crate::io::project_io;
use crate::ops::track_ops::generate_prefix;

const PROJECT_TOML_TEMPLATE: &str = include_str!("../../templates/project.toml");

const INBOX_TEMPLATE: &str = "# Inbox\n";

const TRACK_TEMPLATE: &str = "# {name}\n\n> \n\n## Backlog\n\n## Parked\n\n## Done\n";

/// Validate that a track ID is lowercase alphanumeric with hyphens only.
fn validate_track_id(id: &str) -> Result<(), String> {
    if id.is_empty() {
        return Err("track id cannot be empty".to_string());
    }
    if !id
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err(format!(
            "invalid track id \"{}\" — use lowercase with hyphens (e.g. \"my-track\")",
            id
        ));
    }
    Ok(())
}

/// Infer a project name from a directory name: replace hyphens with spaces, title-case.
fn infer_name(dir_name: &str) -> String {
    dir_name
        .split('-')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(c) => {
                    let upper: String = c.to_uppercase().collect();
                    upper + &chars.collect::<String>()
                }
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

/// Parse --track pairs from the flat Vec<String> produced by clap.
/// Each pair is (id, name).
fn parse_track_pairs(args: &[String]) -> Vec<(&str, &str)> {
    args.chunks(2)
        .filter_map(|chunk| {
            if chunk.len() == 2 {
                Some((chunk[0].as_str(), chunk[1].as_str()))
            } else {
                None
            }
        })
        .collect()
}

/// Render project.toml from the embedded template.
///
/// Replaces `{{PROJECT_NAME}}` with the project name. If tracks are provided,
/// appends `[[tracks]]` entries and `[ids.prefixes]` table at the end.
fn render_project_toml(
    name: &str,
    tracks: &[(&str, &str)],
    prefixes: &[(String, String)],
) -> String {
    let mut output = PROJECT_TOML_TEMPLATE.replace("{{PROJECT_NAME}}", name);

    if tracks.is_empty() {
        return output;
    }

    // Append track entries
    for (id, tname) in tracks {
        output.push_str(&format!(
            "\n[[tracks]]\nid = \"{}\"\nname = \"{}\"\nstate = \"active\"\nfile = \"tracks/{}.md\"\n",
            id, tname, id
        ));
    }

    // Append prefix table
    output.push_str("\n[ids.prefixes]\n");
    for (id, pfx) in prefixes {
        output.push_str(&format!("{} = \"{}\"\n", id, pfx));
    }

    output
}

/// Add Frame-specific entries to .gitignore if a git repo exists.
/// Returns true if entries were actually added.
fn update_gitignore(cwd: &std::path::Path) -> bool {
    // Only act if this is a git repo (has .git dir or file)
    if !cwd.join(".git").exists() {
        return false;
    }

    let gitignore_path = cwd.join(".gitignore");
    let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();

    let entries = [
        "frame/.state.json",
        "frame/.lock",
        "frame/.recovery.log",
        "frame/.actor",
    ];
    let mut to_add = Vec::new();
    for entry in &entries {
        if !existing.lines().any(|line| line.trim() == *entry) {
            to_add.push(*entry);
        }
    }

    if to_add.is_empty() {
        return false;
    }

    let mut content = existing;
    // Ensure we start on a fresh line
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str("\n# frame (added by fr init)\n");
    for entry in &to_add {
        content.push_str(entry);
        content.push('\n');
    }

    fs::write(&gitignore_path, content).is_ok()
}

pub fn cmd_init(args: InitArgs) -> Result<(), Box<dyn std::error::Error>> {
    let cwd = std::env::current_dir()?;
    let frame_dir = cwd.join("frame");

    // Check if already initialized
    if frame_dir.is_dir() && !args.force {
        return Err("frame/ already exists (use --force to reinitialize)".into());
    }

    // Check for parent project and warn
    if let Some(parent) = cwd.parent()
        && let Ok(parent_root) = project_io::discover_project(parent)
    {
        let parent_frame = parent_root.join("frame");
        eprintln!("Note: parent project found at {}/", parent_frame.display());
        eprintln!("Creating new project in ./frame/");
    }

    // Parse track pairs and validate IDs
    let track_pairs = parse_track_pairs(&args.track);
    for (id, _) in &track_pairs {
        validate_track_id(id)?;
    }

    // Check for duplicate track IDs
    let mut seen_ids = std::collections::HashSet::new();
    for (id, _) in &track_pairs {
        if !seen_ids.insert(*id) {
            return Err(format!("duplicate track id \"{}\"", id).into());
        }
    }

    // Infer project name
    let name = args.name.unwrap_or_else(|| {
        cwd.file_name()
            .and_then(|n| n.to_str())
            .map(infer_name)
            .unwrap_or_else(|| "Untitled".to_string())
    });

    // Generate prefixes for tracks
    let mut prefixes = Vec::new();
    let mut existing_prefixes: Vec<String> = Vec::new();
    for (id, _) in &track_pairs {
        let pfx = generate_prefix(id, &existing_prefixes);
        existing_prefixes.push(pfx.clone());
        prefixes.push((id.to_string(), pfx));
    }

    // Create directory structure
    fs::create_dir_all(frame_dir.join("tracks"))?;
    fs::create_dir_all(frame_dir.join("archive"))?;

    // Write project.toml
    let toml_content = render_project_toml(&name, &track_pairs, &prefixes);
    fs::write(frame_dir.join("project.toml"), toml_content)?;

    // Write inbox.md
    fs::write(frame_dir.join("inbox.md"), INBOX_TEMPLATE)?;

    // Create track files
    for (id, tname) in &track_pairs {
        let content = TRACK_TEMPLATE.replace("{name}", tname);
        fs::write(frame_dir.join(format!("tracks/{}.md", id)), content)?;
    }

    // Claim the `null` (primary) token for this working copy. Don't clobber an
    // existing registry on --force reinit.
    if !crate::io::actors::actors_path(&frame_dir).exists() {
        let mut registry = crate::io::actors::ActorRegistry::default();
        let actor_name = crate::io::actors::default_name();
        registry
            .claim("null", &actor_name, None, &crate::io::actors::today())
            .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
        crate::io::actors::write_actors(&frame_dir, &registry)?;
        crate::io::actors::write_actor_token(&frame_dir, "null")?;
    }

    // Register in global project registry
    crate::io::registry::register_project(&name, &cwd);

    // Update .gitignore
    let gitignore_updated = update_gitignore(&cwd);

    // Print summary
    println!("[>] frame initialized");
    println!();
    println!("  project.toml");
    println!("  inbox.md");
    for (id, _) in &track_pairs {
        println!("  tracks/{}.md", id);
    }

    if gitignore_updated {
        println!();
        println!("  added frame/.state.json, frame/.lock, frame/.actor to .gitignore");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_validate_track_id_valid() {
        assert!(validate_track_id("effects").is_ok());
        assert!(validate_track_id("compiler-infra").is_ok());
        assert!(validate_track_id("v2").is_ok());
        assert!(validate_track_id("my-cool-track").is_ok());
    }

    #[test]
    fn test_validate_track_id_invalid() {
        assert!(validate_track_id("My Track").is_err());
        assert!(validate_track_id("UPPER").is_err());
        assert!(validate_track_id("under_score").is_err());
        assert!(validate_track_id("").is_err());
    }

    #[test]
    fn test_infer_name() {
        assert_eq!(infer_name("my-cool-project"), "My Cool Project");
        assert_eq!(infer_name("frame"), "Frame");
        assert_eq!(infer_name("lace-compiler"), "Lace Compiler");
    }

    #[test]
    fn test_parse_track_pairs() {
        let args = vec![
            "effects".to_string(),
            "Effect System".to_string(),
            "infra".to_string(),
            "Infrastructure".to_string(),
        ];
        let pairs = parse_track_pairs(&args);
        assert_eq!(pairs.len(), 2);
        assert_eq!(pairs[0], ("effects", "Effect System"));
        assert_eq!(pairs[1], ("infra", "Infrastructure"));
    }

    #[test]
    fn test_template_embedding() {
        assert!(!PROJECT_TOML_TEMPLATE.is_empty());
        assert!(PROJECT_TOML_TEMPLATE.contains("{{PROJECT_NAME}}"));
    }

    #[test]
    fn test_render_project_toml_no_tracks() {
        let result = render_project_toml("My Project", &[], &[]);
        assert!(result.contains("name = \"My Project\""));
        assert!(!result.contains("{{PROJECT_NAME}}"));
        // Template has commented-out examples (# [[tracks]]), but no real entries
        assert!(!result.contains("\n[[tracks]]"));
        assert!(!result.contains("\n[ids.prefixes]"));
        assert!(result.contains("[clean]"));
        assert!(result.contains("[ui]"));
    }

    #[test]
    fn test_render_project_toml_with_tracks() {
        let tracks = vec![("api", "API Layer"), ("ui", "UI")];
        let prefixes = vec![
            ("api".to_string(), "API".to_string()),
            ("ui".to_string(), "UI".to_string()),
        ];
        let result = render_project_toml("Test", &tracks, &prefixes);
        assert!(result.contains("[[tracks]]"));
        assert!(result.contains("id = \"api\""));
        assert!(result.contains("name = \"API Layer\""));
        assert!(result.contains("file = \"tracks/api.md\""));
        assert!(result.contains("id = \"ui\""));
        assert!(result.contains("name = \"UI\""));
        assert!(result.contains("[ids.prefixes]"));
        assert!(result.contains("api = \"API\""));
        assert!(result.contains("ui = \"UI\""));
    }

    #[test]
    fn test_render_prefix_collision() {
        // "api" and "app" would both naively get "API" — generate_prefix handles this
        let tracks = vec![("api", "API Service"), ("app", "Application")];
        let mut existing_prefixes: Vec<String> = Vec::new();
        let mut prefixes = Vec::new();
        for (id, _) in &tracks {
            let pfx = generate_prefix(id, &existing_prefixes);
            existing_prefixes.push(pfx.clone());
            prefixes.push((id.to_string(), pfx));
        }
        // Prefixes must be distinct
        assert_ne!(prefixes[0].1, prefixes[1].1);

        let result = render_project_toml("Test", &tracks, &prefixes);
        assert!(result.contains(&format!("api = \"{}\"", prefixes[0].1)));
        assert!(result.contains(&format!("app = \"{}\"", prefixes[1].1)));
    }

    #[test]
    fn test_render_round_trip_no_tracks() {
        let result = render_project_toml("Round Trip", &[], &[]);
        let parsed: crate::model::config::ProjectConfig = toml::from_str(&result).unwrap();
        assert_eq!(parsed.project.name, "Round Trip");
    }

    #[test]
    fn test_render_round_trip_with_tracks() {
        let tracks = vec![("api", "API Layer"), ("ui", "UI")];
        let prefixes = vec![
            ("api".to_string(), "API".to_string()),
            ("ui".to_string(), "UI".to_string()),
        ];
        let result = render_project_toml("Round Trip", &tracks, &prefixes);
        let parsed: crate::model::config::ProjectConfig = toml::from_str(&result).unwrap();
        assert_eq!(parsed.project.name, "Round Trip");
        assert_eq!(parsed.tracks.len(), 2);
        assert_eq!(parsed.tracks[0].id, "api");
        assert_eq!(parsed.tracks[1].id, "ui");
        assert_eq!(parsed.ids.prefixes.get("api").unwrap(), "API");
        assert_eq!(parsed.ids.prefixes.get("ui").unwrap(), "UI");
    }

    #[test]
    fn test_update_gitignore_no_git() {
        let tmp = tempfile::TempDir::new().unwrap();
        // No .git dir — should return false
        assert!(!update_gitignore(tmp.path()));
        assert!(!tmp.path().join(".gitignore").exists());
    }

    #[test]
    fn test_update_gitignore_creates_entries() {
        let tmp = tempfile::TempDir::new().unwrap();
        fs::create_dir(tmp.path().join(".git")).unwrap();

        assert!(update_gitignore(tmp.path()));

        let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
        assert!(content.contains("frame/.state.json"));
        assert!(content.contains("frame/.lock"));
        assert!(content.contains("# frame (added by fr init)"));
    }

    #[test]
    fn test_update_gitignore_skips_existing() {
        let tmp = tempfile::TempDir::new().unwrap();
        fs::create_dir(tmp.path().join(".git")).unwrap();
        fs::write(
            tmp.path().join(".gitignore"),
            "frame/.state.json\nframe/.lock\nframe/.recovery.log\nframe/.actor\n",
        )
        .unwrap();

        assert!(!update_gitignore(tmp.path()));
    }

    #[test]
    fn test_update_gitignore_appends_to_existing() {
        let tmp = tempfile::TempDir::new().unwrap();
        fs::create_dir(tmp.path().join(".git")).unwrap();
        fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();

        assert!(update_gitignore(tmp.path()));

        let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
        assert!(content.starts_with("*.log\n"));
        assert!(content.contains("frame/.state.json"));
        assert!(content.contains("frame/.lock"));
    }
}