plan_tooling/parse/
to_json.rs1use 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}