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
// The binary is a thin shell over the library crate — it deliberately
// declares no modules of its own. Re-declaring them here would compile
// a second, private copy of the entire tree, which is how `app`'s unit
// tests previously ended up reachable only via `cargo test --bin
// edamame`. See `src/lib.rs`.
use std::path::PathBuf;
use anyhow::Result;
use edamame::app::App;
use edamame::cli::{self, Invocation, RunOpts};
use edamame::config::{self, Config, LoadedConfig};
use edamame::terminal::{self, Capabilities, ColorDepth, TerminalSetup};
/// Exit status for a command line we couldn't parse. 2 is the
/// long-standing convention for a usage error (as distinct from 1, a
/// program that ran and failed).
const EXIT_USAGE: i32 = 2;
fn main() -> Result<()> {
// ── Parse CLI arguments ────────────────────────────────────────
// `args_os`, not `args`: the latter panics on a non-UTF-8 argument,
// which is a legal file name on Linux. See `cli::args`.
let invocation = Invocation::parse(std::env::args_os().skip(1)).unwrap_or_else(|e| {
eprintln!("edamame: {e}\n\n{}", cli::USAGE);
std::process::exit(EXIT_USAGE);
});
match invocation {
// The informational flags answer and exit without ever touching
// the config directory or the alternate screen — `--doctor`
// enters and leaves it to run the capability probe, but draws
// no frame and prints only after restoring.
Invocation::Help => {
print!("{}", cli::help_text());
Ok(())
}
Invocation::Version => {
println!("{}", cli::version_line());
Ok(())
}
Invocation::Doctor => cli::run_doctor(),
Invocation::Run { file, opts } => run(file, opts),
}
}
/// Start the editor: load config, set up the terminal, probe
/// capabilities, run the app, restore.
fn run(file_path: Option<PathBuf>, opts: RunOpts) -> Result<()> {
// ── Load configuration ─────────────────────────────────────────
// Three files: config.toml (editor/modal/table/image + active theme
// name), keybindings.toml (overrides), themes/<active>.toml (style
// table).
//
// Order matters: scaffold the default files FIRST so a first-run user
// gets a themed editor on their first launch (otherwise `load` reads
// the theme file before it has been written). `ensure_default_files`
// never overwrites existing user files, so this is safe on every
// subsequent run. The `load` fallback also covers the missing-file
// case — if scaffolding fails (e.g. unwritable XDG dir) the compiled
// `Theme::default()` is used.
//
// We need to know the terminal's color depth *before* both of those
// steps: the scaffolder seeds `theme = "256 Dark"` instead of the
// truecolor default on an indexed-color terminal, and `Config::load`
// picks the same capability-appropriate built-in when the active theme
// file is missing on disk. The full capability probe
// (`Capabilities::detect`) writes escape sequences to the terminal and
// must therefore run *after* `terminal::setup` — too late for this
// decision. `detect_color_depth_from_env` inspects `$COLORTERM`,
// `$TERM`, and a handful of terminal-specific env vars only (no I/O),
// so it's safe to call at this point and gives us the same answer the
// full probe will compute later. The full probe remains the source of
// truth for everything else (mouse, keyboard enhancements, image
// support, etc.); this is a one-bit early read, not a parallel
// implementation.
//
// `--no-config` short-circuits all three files: no scaffolding and
// no reads here, and `suppress_config_writes` below closes the write
// half for every site in the process (see `config::persistence`).
// `LoadedConfig::default()` is the same in-memory fallback `load`
// failures already use, so this is an existing tested path rather
// than a second definition of "the built-in defaults".
let truecolor_at_load = Capabilities::detect_color_depth_from_env() == ColorDepth::TrueColor;
let loaded = if opts.no_config {
LoadedConfig::default()
} else {
Config::ensure_default_files(truecolor_at_load);
Config::load(truecolor_at_load, true).unwrap_or_else(|e| {
// Config errors are non-fatal; use defaults and note the problem.
// Can't use tracing here since subscriber isn't set up yet.
eprintln!("Warning: failed to load config: {e}. Using defaults.");
LoadedConfig::default()
})
};
let LoadedConfig {
mut config,
keybindings,
theme,
warnings: config_warnings,
} = loaded;
// ── Apply run flags on top of the loaded config ────────────────
if opts.no_config {
// Take the config directory out of play for the rest of the
// process — reads as well as writes — before `App` exists and so
// before anything can save or enumerate it. Skipping the load
// above is only the startup half: the theme picker and the
// export-stylesheet list read the directory again mid-session.
config::disable_config_dir();
// The welcome modal exists to capture first-run choices *to
// disk*, and a first run opens it non-dismissable (there is no
// prior choice to protect, so it has no Cancel). With saving
// suppressed it has nothing to capture — leaving it on would put
// an unskippable prompt at the head of every triage run for no
// outcome. The capabilities notice is left alone: it is
// dismissable, and what it reports is exactly what someone
// running `--no-config` is usually trying to find out.
config.editor.show_welcome = false;
}
config.dev.logging |= opts.log;
// ── Set up logging (disabled by default) ──────────────────────
let log_guard = if config.dev.logging {
setup_logging()
} else {
None
};
// ── Initialise terminal ────────────────────────────────────────
let TerminalSetup {
terminal,
keyboard_enhancement,
} = terminal::setup()?;
// Install a panic hook so the terminal is always restored, even on panic.
let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = terminal::restore();
orig_hook(info);
}));
// ── Detect capabilities ───────────────────────────────────────
// Must run AFTER EnterAlternateScreen so Picker::from_query_stdio is
// talking to the live terminal, and BEFORE app.run() spawns its event
// reader thread (competing reads would eat the escape-sequence replies).
let capabilities = Capabilities::detect(keyboard_enhancement);
log_capabilities(&capabilities);
// ── Enable mouse reporting ────────────────────────────────────
// Only on terminals that advertise mouse support — sending the enable
// sequence to a terminal that doesn't understand it (TERM=linux, dumb)
// leaves the bytes echoed as literal output. Any failure is non-fatal:
// the app still runs, just without mouse input.
if capabilities.mouse {
if let Err(e) = terminal::enable_mouse() {
tracing::warn!(error = %e, "failed to enable mouse capture");
}
}
// ── Run the app ───────────────────────────────────────────────
let mut app = App::new(
config,
keybindings,
theme,
file_path,
capabilities,
config_warnings,
)?;
let run_result = app.run(terminal);
// ── Restore terminal ──────────────────────────────────────────
terminal::restore()?;
// Point `--log` at what it produced. After `restore`, so the line
// lands on the user's normal screen rather than being swallowed with
// the alternate one — and after dropping the appender guard, so the
// file is flushed and closed by the time we name it.
//
// The guard, not `log_dir()`, is what says a log exists: `log_dir`
// only resolves a path, while `setup_logging` also returns `None`
// when creating that directory failed — in which case no subscriber
// was ever installed and naming the file would send the user after
// something that isn't there.
if opts.log {
let logging_started = log_guard.is_some();
drop(log_guard);
match Config::log_dir().filter(|_| logging_started) {
// The appender rolls daily, so the file name carries a date
// we'd need a date library to render. Naming the directory
// and the pattern is exact without that dependency — and
// "written under" doesn't read as a file path the way the
// bare directory did (`cat`ting it was the obvious next
// move, and it is a directory).
Some(dir) => eprintln!(
"edamame: debug log written under {} (debug.log.<date>)",
dir.display()
),
None => eprintln!("edamame: --log could not open a log file; no log was written"),
}
}
run_result
}
// ── Logging setup ─────────────────────────────────────────────────────────────
/// Initialise the file-based tracing subscriber.
///
/// Returns the non-blocking writer guard; dropping it flushes and closes the
/// log file. The guard must be kept alive for the duration of the program.
fn setup_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
let log_dir = Config::log_dir()?;
if std::fs::create_dir_all(&log_dir).is_err() {
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "debug.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.init();
tracing::info!("edamame starting");
Some(guard)
}
/// Write a one-line summary of the detected terminal capabilities to the log.
fn log_capabilities(caps: &Capabilities) {
tracing::info!(
color_depth = ?caps.color_depth,
mouse = caps.mouse,
image_protocol = ?caps.image_protocol,
unicode_full = caps.unicode_full,
keyboard_enhancement = caps.keyboard_enhancement,
"terminal capabilities detected"
);
}