Skip to main content

plan_tooling/parse/
to_json.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use crate::parse::{Plan, parse_plan_with_display};
5
6const USAGE: &str = r#"Usage:
7  plan_to_json.sh --file <plan.md> [--sprint <n>] [--pretty]
8
9Purpose:
10  Parse a plan markdown file (Plan Format v1) into a stable JSON schema.
11
12Options:
13  --file <path>   Plan file to parse (required)
14  --sprint <n>    Only include a single sprint number (optional)
15  --pretty        Pretty-print JSON (indent=2)
16  -h, --help      Show help
17
18Exit:
19  0: parsed successfully (JSON on stdout)
20  1: parse error (prints error: lines to stderr)
21  2: usage error
22"#;
23
24fn print_usage() {
25    let _ = std::io::stderr().write_all(USAGE.as_bytes());
26}
27
28fn die(msg: &str) -> i32 {
29    eprintln!("plan_to_json: {msg}");
30    2
31}
32
33pub fn run(args: &[String]) -> i32 {
34    let mut file: Option<String> = None;
35    let mut sprint: Option<String> = None;
36    let mut pretty = false;
37
38    let mut i = 0;
39    while i < args.len() {
40        match args[i].as_str() {
41            "--file" => {
42                let Some(v) = args.get(i + 1) else {
43                    return die("missing value for --file");
44                };
45                if v.is_empty() {
46                    return die("missing value for --file");
47                }
48                file = Some(v.to_string());
49                i += 2;
50            }
51            "--sprint" => {
52                let Some(v) = args.get(i + 1) else {
53                    return die("missing value for --sprint");
54                };
55                if v.is_empty() {
56                    return die("missing value for --sprint");
57                }
58                sprint = Some(v.to_string());
59                i += 2;
60            }
61            "--pretty" => {
62                pretty = true;
63                i += 1;
64            }
65            "-h" | "--help" => {
66                print_usage();
67                return 0;
68            }
69            other => {
70                return die(&format!("unknown argument: {other}"));
71            }
72        }
73    }
74
75    let Some(file_arg) = file else {
76        print_usage();
77        return 2;
78    };
79
80    let repo_root = crate::repo_root::detect();
81    let display_path = file_arg.clone();
82    let read_path = resolve_repo_relative(&repo_root, Path::new(&file_arg));
83
84    if !read_path.is_file() {
85        eprintln!("error: plan file not found: {display_path}");
86        return 1;
87    }
88
89    let mut plan: Plan;
90    let errors: Vec<String>;
91    match parse_plan_with_display(&read_path, &display_path) {
92        Ok((p, errs)) => {
93            plan = p;
94            errors = errs;
95        }
96        Err(err) => {
97            eprintln!("error: {display_path}: {err}");
98            return 1;
99        }
100    }
101
102    plan.file = path_to_posix(&maybe_relativize(&read_path, &repo_root));
103
104    if let Some(sprint_raw) = sprint.as_deref() {
105        let want = match sprint_raw.parse::<i32>() {
106            Ok(v) => v,
107            Err(_) => {
108                eprintln!(
109                    "error: invalid --sprint (expected int): {}",
110                    crate::repr::py_repr(sprint_raw)
111                );
112                return 2;
113            }
114        };
115        plan.sprints.retain(|s| s.number == want);
116    }
117
118    if !errors.is_empty() {
119        for err in errors {
120            eprintln!("error: {err}");
121        }
122        return 1;
123    }
124
125    let json = if pretty {
126        match serde_json::to_string_pretty(&plan) {
127            Ok(v) => v,
128            Err(err) => {
129                eprintln!("error: failed to encode JSON: {err}");
130                return 1;
131            }
132        }
133    } else {
134        match serde_json::to_string(&plan) {
135            Ok(v) => v,
136            Err(err) => {
137                eprintln!("error: failed to encode JSON: {err}");
138                return 1;
139            }
140        }
141    };
142
143    println!("{json}");
144    0
145}
146
147fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
148    if path.is_absolute() {
149        return path.to_path_buf();
150    }
151    repo_root.join(path)
152}
153
154fn maybe_relativize(path: &Path, repo_root: &Path) -> PathBuf {
155    let Ok(path_abs) = path.canonicalize() else {
156        return path.to_path_buf();
157    };
158    let Ok(root_abs) = repo_root.canonicalize() else {
159        return path_abs;
160    };
161    match path_abs.strip_prefix(&root_abs) {
162        Ok(rel) => rel.to_path_buf(),
163        Err(_) => path_abs,
164    }
165}
166
167fn path_to_posix(path: &Path) -> String {
168    path.to_string_lossy()
169        .replace(std::path::MAIN_SEPARATOR, "/")
170}