agentmux_common/
srv_stderr.rs1pub const MIGRATION_FAILED_PREFIX: &str = "AGENTMUXSRV-MIGRATION-FAILED";
44
45pub fn migration_failed_line(err: &str) -> String {
49 format!("{} error:{}", MIGRATION_FAILED_PREFIX, one_line(err))
50}
51
52pub fn parse_migration_failed_line(line: &str) -> Option<String> {
57 let rest = line.strip_prefix(MIGRATION_FAILED_PREFIX)?;
58 let rest = rest.trim_start();
59 let msg = rest.strip_prefix("error:").unwrap_or(rest).trim();
60 Some(if msg.is_empty() { "unknown error".to_string() } else { msg.to_string() })
61}
62
63fn one_line(s: &str) -> String {
64 s.split(['\n', '\r'])
65 .map(str::trim)
66 .filter(|part| !part.is_empty())
67 .collect::<Vec<_>>()
68 .join(" | ")
69}
70
71pub const MIGRATION_BEGIN_PREFIX: &str = "AGENTMUXSRV-MIGRATION-BEGIN";
74pub const MIGRATION_END_PREFIX: &str = "AGENTMUXSRV-MIGRATION-END";
78
79pub struct MigrationBegin {
81 pub id: String,
82 pub description: String,
83}
84
85pub struct MigrationEnd {
87 pub id: String,
88 pub duration_ms: u64,
89}
90
91pub fn migration_begin_line(id: &str, description: &str) -> String {
96 format!("{} id:{} description:{}", MIGRATION_BEGIN_PREFIX, id, one_line(description))
97}
98
99pub fn migration_end_line(id: &str, duration_ms: u64) -> String {
100 format!("{} id:{} duration_ms:{}", MIGRATION_END_PREFIX, id, duration_ms)
101}
102
103pub fn parse_migration_begin_line(line: &str) -> Option<MigrationBegin> {
107 let rest = line.strip_prefix(MIGRATION_BEGIN_PREFIX)?.trim_start();
108 let rest = rest.strip_prefix("id:")?;
109 let (id, rest) = rest.split_once(' ')?;
110 let description = rest.strip_prefix("description:").unwrap_or(rest).trim();
111 Some(MigrationBegin { id: id.to_string(), description: description.to_string() })
112}
113
114pub fn parse_migration_end_line(line: &str) -> Option<MigrationEnd> {
117 let rest = line.strip_prefix(MIGRATION_END_PREFIX)?.trim_start();
118 let rest = rest.strip_prefix("id:")?;
119 let (id, rest) = rest.split_once(' ')?;
120 let ms_str = rest.trim().strip_prefix("duration_ms:")?;
121 let duration_ms = ms_str.trim().parse::<u64>().ok()?;
122 Some(MigrationEnd { id: id.to_string(), duration_ms })
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn line_is_prefixed_and_single_line() {
131 let line = migration_failed_line("migration 0007 failed:\r\n FOREIGN KEY constraint failed\n");
132 assert_eq!(
133 line,
134 "AGENTMUXSRV-MIGRATION-FAILED error:migration 0007 failed: | FOREIGN KEY constraint failed"
135 );
136 assert!(!line.contains('\n'));
137 }
138
139 #[test]
140 fn round_trips_through_parse() {
141 let msg = "run_pending_migrations: open shared store: disk I/O error";
142 assert_eq!(parse_migration_failed_line(&migration_failed_line(msg)).as_deref(), Some(msg));
143 }
144
145 #[test]
146 fn parse_tolerates_missing_error_field() {
147 assert_eq!(
148 parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED something broke").as_deref(),
149 Some("something broke")
150 );
151 }
152
153 #[test]
154 fn parse_never_returns_empty_message() {
155 assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED").as_deref(), Some("unknown error"));
156 assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED error:").as_deref(), Some("unknown error"));
157 }
158
159 #[test]
160 fn parse_ignores_other_protocol_lines() {
161 assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATING migrations:3"), None);
162 assert_eq!(parse_migration_failed_line("AGENTMUXSRV-ESTART ws:127.0.0.1:1 web:127.0.0.1:2"), None);
163 assert_eq!(parse_migration_failed_line("plain stderr noise"), None);
164 }
165
166 #[test]
167 fn migrating_prefix_is_not_a_prefix_of_failed() {
168 assert!(!MIGRATION_FAILED_PREFIX.starts_with("AGENTMUXSRV-MIGRATING"));
171 assert!(!"AGENTMUXSRV-MIGRATING".starts_with(MIGRATION_FAILED_PREFIX));
172 }
173
174 #[test]
175 fn none_of_the_four_migration_prefixes_are_a_prefix_of_another() {
176 let all = [
180 "AGENTMUXSRV-MIGRATING",
181 MIGRATION_FAILED_PREFIX,
182 MIGRATION_BEGIN_PREFIX,
183 MIGRATION_END_PREFIX,
184 ];
185 for a in all {
186 for b in all {
187 if a == b { continue; }
188 assert!(!a.starts_with(b), "{a:?} must not start with {b:?}");
189 }
190 }
191 }
192
193 #[test]
194 fn begin_line_round_trips_through_parse() {
195 let line = migration_begin_line("0020_agent_color_backfill", "backfill default agent colors");
196 let parsed = parse_migration_begin_line(&line).expect("must parse");
197 assert_eq!(parsed.id, "0020_agent_color_backfill");
198 assert_eq!(parsed.description, "backfill default agent colors");
199 }
200
201 #[test]
202 fn begin_line_flattens_a_multiline_description() {
203 let line = migration_begin_line("0007_x", "line one\nline two");
204 assert!(!line.contains('\n'));
205 assert_eq!(parse_migration_begin_line(&line).unwrap().description, "line one | line two");
206 }
207
208 #[test]
209 fn end_line_round_trips_through_parse() {
210 let line = migration_end_line("0020_agent_color_backfill", 147);
211 let parsed = parse_migration_end_line(&line).expect("must parse");
212 assert_eq!(parsed.id, "0020_agent_color_backfill");
213 assert_eq!(parsed.duration_ms, 147);
214 }
215
216 #[test]
217 fn begin_and_end_parsers_ignore_other_protocol_lines_and_each_other() {
218 assert!(parse_migration_begin_line("AGENTMUXSRV-MIGRATING migrations:3").is_none());
219 assert!(parse_migration_begin_line("AGENTMUXSRV-ESTART ws:x web:y").is_none());
220 assert!(parse_migration_begin_line(&migration_end_line("0007_x", 5)).is_none());
221 assert!(parse_migration_end_line(&migration_begin_line("0007_x", "d")).is_none());
222 }
223
224 #[test]
225 fn malformed_begin_and_end_lines_do_not_panic() {
226 assert!(parse_migration_begin_line(MIGRATION_BEGIN_PREFIX).is_none());
227 assert!(parse_migration_begin_line(&format!("{MIGRATION_BEGIN_PREFIX} garbage")).is_none());
228 assert!(parse_migration_end_line(MIGRATION_END_PREFIX).is_none());
229 assert!(parse_migration_end_line(&format!("{MIGRATION_END_PREFIX} id:0007_x duration_ms:notanumber")).is_none());
230 }
231}