Skip to content

Commit 4e8ca9a

Browse files
committed
Add esp-config TUI
1 parent ca07fbc commit 4e8ca9a

File tree

5 files changed

+1020
-3
lines changed

5 files changed

+1020
-3
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ jobs:
179179
- uses: Swatinem/rust-cache@v2
180180

181181
# Run tests in esp-config
182-
- run: cd esp-config && cargo test --features build
182+
- run: cd esp-config && cargo test --features build,tui
183183

184184
# Run tests in esp-bootloader-esp-idf
185185
- run: cd esp-bootloader-esp-idf && cargo test --features=std

esp-config/Cargo.toml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,26 @@ license = "MIT OR Apache-2.0"
1212
bench = false
1313
test = true
1414

15+
[[bin]]
16+
name = "esp-config"
17+
required-features = ["tui"]
18+
1519
[dependencies]
1620
document-features = "0.2.11"
21+
22+
# used by the `build` and `tui` feature
1723
serde = { version = "1.0.197", features = ["derive"], optional = true }
18-
serde_json = { version = "1.0.0", optional = true }
24+
serde_json = { version = "1.0.0", features = ["arbitrary_precision"], optional = true }
25+
26+
# used by the `tui` feature
27+
clap = { version = "4.5.32", features = ["derive"], optional = true }
28+
crossterm = { version = "0.28.1", optional = true }
29+
env_logger = { version = "0.11.7", optional = true }
30+
log = { version = "0.4.26", optional = true }
31+
ratatui = { version = "0.29.0", features = ["crossterm", "unstable"], optional = true }
32+
toml_edit = { version = "0.22.26", optional = true }
33+
tui-textarea = { version = "0.7.0", optional = true }
34+
walkdir = { version = "2.5.0", optional = true }
1935

2036
[dev-dependencies]
2137
temp-env = "0.3.6"
@@ -24,3 +40,18 @@ pretty_assertions = "1.4.1"
2440
[features]
2541
## Enable the generation and parsing of a config
2642
build = ["dep:serde","dep:serde_json"]
43+
44+
## The TUI
45+
tui = [
46+
"dep:clap",
47+
"dep:crossterm",
48+
"dep:env_logger",
49+
"dep:log",
50+
"dep:ratatui",
51+
"dep:toml_edit",
52+
"dep:tui-textarea",
53+
"dep:walkdir",
54+
"dep:serde",
55+
"dep:serde_json",
56+
"build",
57+
]

esp-config/src/bin/esp-config/main.rs

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
use std::{
2+
collections::HashMap,
3+
error::Error,
4+
path::{Path, PathBuf},
5+
};
6+
7+
use clap::Parser;
8+
use env_logger::{Builder, Env};
9+
use esp_config::{ConfigOption, Value};
10+
use serde::Deserialize;
11+
use toml_edit::{DocumentMut, Formatted, Item, Table};
12+
use walkdir::WalkDir;
13+
14+
mod tui;
15+
16+
#[derive(Parser, Debug)]
17+
#[command(author, version, about, long_about = None)]
18+
struct Args {
19+
/// Root of the project
20+
#[arg(short = 'P', long)]
21+
path: Option<PathBuf>,
22+
}
23+
24+
#[derive(Debug, Clone, PartialEq, Eq)]
25+
pub struct CrateConfig {
26+
name: String,
27+
options: Vec<ConfigItem>,
28+
}
29+
30+
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
31+
struct ConfigItem {
32+
option: ConfigOption,
33+
actual_value: Value,
34+
}
35+
36+
fn main() -> Result<(), Box<dyn Error>> {
37+
Builder::from_env(Env::default().default_filter_or(log::LevelFilter::Info.as_str()))
38+
.format_target(false)
39+
.init();
40+
41+
let args = Args::parse();
42+
43+
let work_dir = args.path.clone().unwrap_or(".".into());
44+
45+
ensure_fresh_build(&work_dir)?;
46+
47+
let mut configs = parse_configs(&work_dir)?;
48+
let initial_configs = configs.clone();
49+
let mut previous_config = initial_configs.clone();
50+
51+
let mut errors_to_show = None;
52+
53+
loop {
54+
let repository = tui::Repository::new(configs.clone());
55+
56+
// TUI stuff ahead
57+
let terminal = tui::init_terminal()?;
58+
59+
// create app and run it
60+
let updated_cfg = tui::App::new(errors_to_show, repository).run(terminal)?;
61+
62+
tui::restore_terminal()?;
63+
64+
// done with the TUI
65+
if let Some(updated_cfg) = updated_cfg {
66+
configs = updated_cfg.clone();
67+
apply_config(&work_dir, updated_cfg.clone(), previous_config.clone())?;
68+
previous_config = updated_cfg;
69+
} else {
70+
println!("Reverted configuration...");
71+
apply_config(&work_dir, initial_configs, vec![])?;
72+
break;
73+
}
74+
75+
if let Some(errors) = check_build_after_changes(&work_dir) {
76+
errors_to_show = Some(errors);
77+
} else {
78+
println!("Updated configuration...");
79+
break;
80+
}
81+
}
82+
83+
Ok(())
84+
}
85+
86+
fn apply_config(
87+
path: &Path,
88+
updated_cfg: Vec<CrateConfig>,
89+
previous_cfg: Vec<CrateConfig>,
90+
) -> Result<(), Box<dyn Error>> {
91+
let config_toml = path.join(".cargo/config.toml");
92+
93+
let mut config = std::fs::read_to_string(&config_toml)?
94+
.as_str()
95+
.parse::<DocumentMut>()?;
96+
97+
if !config.contains_key("env") {
98+
config.insert("env", Item::Table(Table::new()));
99+
}
100+
101+
let envs = config.get_mut("env").unwrap().as_table_mut().unwrap();
102+
103+
for cfg in updated_cfg {
104+
let prefix = cfg.name.to_ascii_uppercase().replace("-", "_");
105+
let previous_crate_cfg = previous_cfg.iter().find(|c| c.name == cfg.name);
106+
107+
for option in cfg.options {
108+
let previous_option = previous_crate_cfg.and_then(|c| {
109+
c.options
110+
.iter()
111+
.find(|o| o.option.name == option.option.name)
112+
});
113+
114+
let key = format!(
115+
"{prefix}_CONFIG_{}",
116+
option.option.name.to_ascii_uppercase().replace("-", "_")
117+
);
118+
119+
// avoid updating unchanged options to keep the comments (if any)
120+
if Some(&option.actual_value) != previous_option.map(|option| &option.actual_value) {
121+
if option.actual_value != option.option.default_value {
122+
let value = toml_edit::Value::String(Formatted::new(format!(
123+
"{}",
124+
option.actual_value
125+
)));
126+
127+
envs.insert(&key, Item::Value(value));
128+
} else {
129+
envs.remove(&key);
130+
}
131+
}
132+
}
133+
}
134+
135+
std::fs::write(&config_toml, config.to_string().as_bytes())?;
136+
137+
Ok(())
138+
}
139+
140+
fn parse_configs(path: &Path) -> Result<Vec<CrateConfig>, Box<dyn Error>> {
141+
// we cheat by just trying to find the latest version of the config files
142+
// this should be fine since we force a fresh build before
143+
let mut candidates: Vec<_> = WalkDir::new(path.join("target"))
144+
.into_iter()
145+
.filter_entry(|entry| {
146+
entry.file_type().is_dir() || {
147+
if let Some(name) = entry.file_name().to_str() {
148+
name.ends_with("_config_data.json")
149+
} else {
150+
false
151+
}
152+
}
153+
})
154+
.filter(|entry| !entry.as_ref().unwrap().file_type().is_dir())
155+
.map(|entry| entry.unwrap())
156+
.collect();
157+
candidates.sort_by_key(|entry| entry.metadata().unwrap().modified().unwrap());
158+
159+
let mut crate_config_table_to_json: HashMap<String, PathBuf> = HashMap::new();
160+
161+
for e in candidates {
162+
if e.file_name()
163+
.to_str()
164+
.unwrap()
165+
.ends_with("_config_data.json")
166+
{
167+
let crate_name = e
168+
.file_name()
169+
.to_str()
170+
.unwrap()
171+
.replace("_config_data.json", "")
172+
.replace("_", "-");
173+
crate_config_table_to_json.insert(crate_name.clone(), e.path().to_path_buf());
174+
}
175+
}
176+
177+
let mut configs = Vec::new();
178+
179+
for (crate_name, path) in crate_config_table_to_json {
180+
let options =
181+
serde_json::from_str::<Vec<ConfigItem>>(std::fs::read_to_string(&path)?.as_str())
182+
.map_err(|e| {
183+
format!(
184+
"Unable to read config file {:?} - try `cargo clean` first ({e:?})",
185+
path
186+
)
187+
})?
188+
.iter()
189+
.filter(|option| option.option.active)
190+
.cloned()
191+
.collect();
192+
193+
configs.push(CrateConfig {
194+
name: crate_name,
195+
options,
196+
});
197+
}
198+
configs.sort_by_key(|entry| entry.name.clone());
199+
200+
if configs.is_empty() {
201+
return Err("No config files found.".into());
202+
}
203+
204+
Ok(configs)
205+
}
206+
207+
fn ensure_fresh_build(path: &PathBuf) -> Result<(), Box<dyn Error>> {
208+
let status = std::process::Command::new("cargo")
209+
.arg("build")
210+
.current_dir(path)
211+
.status()?;
212+
213+
if !status.success() {
214+
return Err("Your project doesn't build. Fix the errors first.".into());
215+
}
216+
217+
Ok(())
218+
}
219+
220+
fn check_build_after_changes(path: &PathBuf) -> Option<String> {
221+
println!("Check configuration...");
222+
223+
let status = std::process::Command::new("cargo")
224+
.arg("build")
225+
.current_dir(path)
226+
.stdout(std::process::Stdio::inherit())
227+
.output();
228+
229+
if let Ok(status) = &status {
230+
if status.status.success() {
231+
return None;
232+
}
233+
}
234+
235+
let mut errors = String::new();
236+
237+
for line in String::from_utf8(status.unwrap().stderr)
238+
.unwrap_or_default()
239+
.lines()
240+
{
241+
if line.contains("the evaluated program panicked at '") {
242+
let error = line[line.find('\'').unwrap() + 1..].to_string();
243+
let error = error[..error.find("',").unwrap_or(error.len())].to_string();
244+
errors.push_str(&format!("{error}\n"));
245+
}
246+
}
247+
248+
Some(errors)
249+
}

0 commit comments

Comments
 (0)