1pub fn bashrc_snippet() -> String {
8 SNIPPET.to_string()
9}
10
11pub const BLOCK_BEGIN: &str = "# >>> mnemo init >>>";
14pub const BLOCK_END: &str = "# <<< mnemo init <<<";
15
16const SNIPPET_BEGIN: &str = "# >>> mnemo >>>";
18
19const VERSION_MARKER: &str = "# mnemo shell integration version:";
22
23pub const SNIPPET_VERSION: u32 = 2;
27
28pub fn wrapped_block() -> String {
30 format!("{BLOCK_BEGIN}\n{}{BLOCK_END}\n", bashrc_snippet())
31}
32
33pub fn has_block(content: &str) -> bool {
35 content.contains(SNIPPET_BEGIN) || content.contains("__mnemo_record")
36}
37
38pub fn count_blocks(content: &str) -> usize {
40 content.matches(SNIPPET_BEGIN).count()
41}
42
43pub fn has_ctrl_r_bind(content: &str) -> bool {
45 content.contains("__mnemo_search") || content.contains("\\C-r")
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BlockState {
51 Absent,
53 Legacy,
55 Current,
57}
58
59pub fn block_version(content: &str) -> Option<u32> {
61 content
62 .lines()
63 .find_map(|line| line.trim().strip_prefix(VERSION_MARKER))
64 .and_then(|rest| rest.trim().parse().ok())
65}
66
67pub fn block_state(content: &str) -> BlockState {
74 if !has_block(content) {
75 return BlockState::Absent;
76 }
77 let up_to_date = match block_version(content) {
78 Some(version) => version >= SNIPPET_VERSION,
79 None => content.contains("MNEMO_SESSION_ID"),
80 };
81 if up_to_date {
82 BlockState::Current
83 } else {
84 BlockState::Legacy
85 }
86}
87
88fn compact_now() -> String {
90 let ts = crate::db::now_timestamp(); let date = ts.get(0..10).unwrap_or("").replace('-', "");
92 let time = ts.get(11..19).unwrap_or("").replace(':', "");
93 format!("{date}-{time}")
94}
95
96pub fn install_block(bashrc: &std::path::Path) -> anyhow::Result<bool> {
105 let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
106 if has_block(&existing) {
107 return Ok(false);
108 }
109
110 if bashrc.exists() {
111 let backup = bashrc.with_file_name(format!(".bashrc.mnemo.bak.{}", compact_now()));
112 std::fs::copy(bashrc, &backup)?;
113 }
114
115 let mut content = existing;
116 if !content.is_empty() && !content.ends_with('\n') {
117 content.push('\n');
118 }
119 content.push_str(&wrapped_block());
120 std::fs::write(bashrc, content)?;
121 Ok(true)
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum BlockRepair {
127 Created,
129 Deduplicated,
131 CtrlRRestored,
133 Upgraded,
135 AlreadyOk,
137}
138
139pub fn strip_blocks(content: &str) -> String {
144 let mut out = String::new();
145 let mut in_block = false;
146 for line in content.lines() {
147 let trimmed = line.trim();
148 if trimmed == BLOCK_BEGIN {
149 in_block = true;
150 continue;
151 }
152 if trimmed == BLOCK_END {
153 in_block = false;
154 continue;
155 }
156 if !in_block {
157 out.push_str(line);
158 out.push('\n');
159 }
160 }
161 out
162}
163
164pub fn repair_block(bashrc: &std::path::Path) -> anyhow::Result<BlockRepair> {
170 let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
171
172 if !has_block(&existing) {
173 install_block(bashrc)?;
174 return Ok(BlockRepair::Created);
175 }
176
177 let duplicated = count_blocks(&existing) > 1;
178 let missing_ctrl_r = !has_ctrl_r_bind(&existing);
179 let outdated = block_state(&existing) == BlockState::Legacy;
180 if !duplicated && !missing_ctrl_r && !outdated {
181 return Ok(BlockRepair::AlreadyOk);
182 }
183
184 backup_and_replace(bashrc, &existing)?;
185
186 Ok(if duplicated {
187 BlockRepair::Deduplicated
188 } else if outdated {
189 BlockRepair::Upgraded
190 } else {
191 BlockRepair::CtrlRRestored
192 })
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum ShellUpgrade {
198 NotInstalled,
200 AlreadyCurrent,
202 Upgraded { backup: std::path::PathBuf },
204}
205
206pub fn upgrade_block(bashrc: &std::path::Path) -> anyhow::Result<ShellUpgrade> {
212 let existing = std::fs::read_to_string(bashrc).unwrap_or_default();
213 match block_state(&existing) {
214 BlockState::Absent => Ok(ShellUpgrade::NotInstalled),
215 BlockState::Current => Ok(ShellUpgrade::AlreadyCurrent),
216 BlockState::Legacy => {
217 let backup = backup_and_replace(bashrc, &existing)?;
218 Ok(ShellUpgrade::Upgraded { backup })
219 }
220 }
221}
222
223fn backup_and_replace(
229 bashrc: &std::path::Path,
230 existing: &str,
231) -> anyhow::Result<std::path::PathBuf> {
232 let backup = bashrc.with_file_name(format!(".bashrc.mnemo.bak.{}", compact_now()));
233 std::fs::copy(bashrc, &backup)?;
234
235 let mut content = strip_blocks(existing);
236 while content.ends_with("\n\n") {
237 content.pop();
238 }
239 if !content.is_empty() && !content.ends_with('\n') {
240 content.push('\n');
241 }
242 content.push_str(&wrapped_block());
243 std::fs::write(bashrc, content)?;
244 Ok(backup)
245}
246
247const SNIPPET: &str = r#"# >>> mnemo >>>
248# mnemo shell integration version: 2
249# Identifiant de session : regroupe les commandes d'un même shell interactif
250# (voir `mnemo session`). Conservé pour toute la durée de vie du shell.
251if [ -z "${MNEMO_SESSION_ID:-}" ]; then
252 export MNEMO_SESSION_ID="$(date +%Y%m%dT%H%M%S)-$$"
253fi
254# Enregistre automatiquement chaque commande dans mnemo.
255__mnemo_record() {
256 local __mnemo_exit=$?
257 local __mnemo_cmd
258 __mnemo_cmd=$(HISTTIMEFORMAT='' history 1 2>/dev/null | sed 's/^ *[0-9]\+ *//')
259 if [ -n "$__mnemo_cmd" ] && [ "$__mnemo_cmd" != "$__MNEMO_LAST_CMD" ]; then
260 case "$__mnemo_cmd" in
261 mnemo|mnemo\ *) ;;
262 *)
263 __MNEMO_LAST_CMD="$__mnemo_cmd"
264 mnemo add --cmd "$__mnemo_cmd" --cwd "$PWD" --exit-code "$__mnemo_exit" >/dev/null 2>&1
265 ;;
266 esac
267 fi
268 return $__mnemo_exit
269}
270case "$PROMPT_COMMAND" in
271 *__mnemo_record*) ;;
272 *) PROMPT_COMMAND="__mnemo_record${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;
273esac
274
275# Ctrl+R : ouvre la recherche TUI et insère la commande choisie.
276__mnemo_search() {
277 local __mnemo_selected
278 __mnemo_selected=$(mnemo search 2>/dev/null)
279 if [ -n "$__mnemo_selected" ]; then
280 READLINE_LINE="$__mnemo_selected"
281 READLINE_POINT=${#READLINE_LINE}
282 fi
283}
284bind -x '"\C-r": __mnemo_search' 2>/dev/null
285# <<< mnemo <<<
286"#;
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn snippet_contient_les_elements_cles() {
294 let s = bashrc_snippet();
295 assert!(s.contains("__mnemo_record"));
296 assert!(s.contains("PROMPT_COMMAND"));
297 assert!(s.contains("mnemo add"));
298 assert!(s.contains("mnemo|mnemo\\ *"));
300 }
301
302 #[test]
303 fn snippet_declare_la_version_courante() {
304 let s = bashrc_snippet();
305 assert!(
306 s.contains(&format!("{VERSION_MARKER} {SNIPPET_VERSION}")),
307 "le snippet doit déclarer la version {SNIPPET_VERSION}"
308 );
309 assert_eq!(block_version(&s), Some(SNIPPET_VERSION));
310 assert_eq!(block_state(&s), BlockState::Current);
311 }
312
313 #[test]
314 fn block_state_distingue_absent_legacy_courant() {
315 assert_eq!(block_state("export FOO=1\n"), BlockState::Absent);
317
318 let legacy =
320 format!("{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record() {{ :; }}\n{BLOCK_END}\n");
321 assert_eq!(block_state(&legacy), BlockState::Legacy);
322
323 assert_eq!(block_state(&wrapped_block()), BlockState::Current);
325
326 let sans_marqueur = format!(
328 "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\nexport MNEMO_SESSION_ID=x\n__mnemo_record\n{BLOCK_END}\n"
329 );
330 assert_eq!(block_state(&sans_marqueur), BlockState::Current);
331 }
332
333 #[test]
334 fn upgrade_block_met_a_niveau_un_bloc_legacy() {
335 let dir = tempfile::tempdir().unwrap();
336 let bashrc = dir.path().join(".bashrc");
337
338 let legacy = format!(
340 "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record() {{ :; }}\nbind -x '\"\\C-r\": x'\n{BLOCK_END}\n"
341 );
342 std::fs::write(&bashrc, format!("export FOO=1\n{legacy}export BAR=2\n")).unwrap();
343
344 match upgrade_block(&bashrc).unwrap() {
346 ShellUpgrade::Upgraded { backup } => assert!(backup.exists()),
347 other => panic!("attendu Upgraded, obtenu {other:?}"),
348 }
349 let after = std::fs::read_to_string(&bashrc).unwrap();
350 assert_eq!(block_state(&after), BlockState::Current);
351 assert_eq!(count_blocks(&after), 1);
352 assert!(after.contains("export FOO=1"));
353 assert!(after.contains("export BAR=2"));
354
355 assert_eq!(
357 upgrade_block(&bashrc).unwrap(),
358 ShellUpgrade::AlreadyCurrent
359 );
360 }
361
362 #[test]
363 fn upgrade_block_refuse_si_aucun_bloc() {
364 let dir = tempfile::tempdir().unwrap();
365 let bashrc = dir.path().join(".bashrc");
366 std::fs::write(&bashrc, "export FOO=1\n").unwrap();
367 assert_eq!(upgrade_block(&bashrc).unwrap(), ShellUpgrade::NotInstalled);
368 assert_eq!(std::fs::read_to_string(&bashrc).unwrap(), "export FOO=1\n");
370 }
371
372 #[test]
373 fn repair_block_met_a_niveau_un_bloc_legacy() {
374 let dir = tempfile::tempdir().unwrap();
375 let bashrc = dir.path().join(".bashrc");
376 let legacy = format!(
377 "{BLOCK_BEGIN}\n{SNIPPET_BEGIN}\n__mnemo_record\nbind -x '\"\\C-r\": x'\n{BLOCK_END}\n"
378 );
379 std::fs::write(&bashrc, legacy).unwrap();
380 assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Upgraded);
381 let after = std::fs::read_to_string(&bashrc).unwrap();
382 assert_eq!(block_state(&after), BlockState::Current);
383 }
384
385 #[test]
386 fn detection_du_bloc_et_des_doublons() {
387 let empty = "export FOO=1\n";
388 assert!(!has_block(empty));
389 assert_eq!(count_blocks(empty), 0);
390
391 let one = wrapped_block();
392 assert!(has_block(&one));
393 assert_eq!(count_blocks(&one), 1);
394 assert!(has_ctrl_r_bind(&one));
395
396 let two = format!("{one}\n{one}");
397 assert_eq!(count_blocks(&two), 2);
398 }
399
400 #[test]
401 fn strip_blocks_retire_le_bloc_encadre() {
402 let avant = format!("export FOO=1\n{}export BAR=2\n", wrapped_block());
403 let apres = strip_blocks(&avant);
404 assert!(!has_block(&apres));
405 assert!(apres.contains("export FOO=1"));
406 assert!(apres.contains("export BAR=2"));
407 }
408
409 #[test]
410 fn repair_block_deduplique_et_ajoute() {
411 let dir = tempfile::tempdir().unwrap();
412 let bashrc = dir.path().join(".bashrc");
413
414 std::fs::write(&bashrc, "export FOO=1\n").unwrap();
416 assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Created);
417 assert_eq!(count_blocks(&std::fs::read_to_string(&bashrc).unwrap()), 1);
418
419 assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::AlreadyOk);
421
422 let one = std::fs::read_to_string(&bashrc).unwrap();
424 std::fs::write(&bashrc, format!("{one}{}", wrapped_block())).unwrap();
425 assert_eq!(repair_block(&bashrc).unwrap(), BlockRepair::Deduplicated);
426 let after = std::fs::read_to_string(&bashrc).unwrap();
427 assert_eq!(count_blocks(&after), 1);
428 assert!(has_ctrl_r_bind(&after));
429 }
430}