1use super::commands::{Command, apply_command, command_key};
4use super::duration::Duration;
5use super::score::{NoteAddr, Score};
6use super::validate::validate;
7use crate::Error;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum StructuralChangeDiagnosticKind {
18 Conflict,
19 Unsupported,
20 Invalid,
21 ValidationFailed,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct StructuralChangeDiagnostic {
27 pub kind: StructuralChangeDiagnosticKind,
28 pub message: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct StructuralMeasureStructure {
34 pub part: usize,
35 pub staff: usize,
36 pub measure: usize,
37 pub exists: bool,
38 pub voice_note_counts: [usize; 4],
39 pub source_voice_numbers: [Option<u32>; 4],
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct StructuralChangePlan {
50 pub contract_version: u32,
51 pub command_key: String,
52 pub can_apply: bool,
53 pub affected_addresses: Vec<NoteAddr>,
54 pub resulting_measures: Vec<StructuralMeasureStructure>,
55 pub diagnostics: Vec<StructuralChangeDiagnostic>,
56}
57
58fn is_structural_command(command: &Command) -> bool {
59 matches!(
60 command,
61 Command::ExchangeVoices(_)
62 | Command::MoveOrCopyVoiceRange(_)
63 | Command::SplitMeasure(_)
64 | Command::JoinMeasures(_)
65 | Command::ImplodeStaves(_)
66 | Command::ExplodeVoices(_)
67 | Command::ExplodeChordPitches(_)
68 | Command::ScaleVoiceRange(_)
69 )
70}
71
72fn diagnostic_kind(message: &str) -> StructuralChangeDiagnosticKind {
73 if message.contains("not yet supported") || message.contains("does not support") {
74 StructuralChangeDiagnosticKind::Unsupported
75 } else if message.contains("discard")
76 || message.contains("destination")
77 || message.contains("onto itself")
78 || message.contains("conflict")
79 {
80 StructuralChangeDiagnosticKind::Conflict
81 } else {
82 StructuralChangeDiagnosticKind::Invalid
83 }
84}
85
86#[derive(Clone, PartialEq, Eq)]
87struct NoteRecord {
88 address: NoteAddr,
89 duration: Duration,
90 dot_count: u8,
91}
92
93fn note_records(score: &Score) -> BTreeMap<String, Vec<NoteRecord>> {
94 let mut addresses = BTreeMap::new();
95 for (part, part_data) in score.parts.iter().enumerate() {
96 for (staff, staff_data) in part_data.staves.iter().enumerate() {
97 for (measure, measure_data) in staff_data.measures.iter().enumerate() {
98 for (voice, notes) in measure_data.voices.iter().enumerate() {
99 for (note, note_data) in notes.iter().enumerate() {
100 addresses
101 .entry(note_data.id.clone())
102 .or_insert_with(Vec::new)
103 .push(NoteRecord {
104 address: NoteAddr {
105 part,
106 staff,
107 measure,
108 voice,
109 note,
110 },
111 duration: note_data.duration.clone(),
112 dot_count: note_data.dot_count,
113 });
114 }
115 }
116 }
117 }
118 }
119 addresses
120}
121
122fn sorted_unique_addresses(mut addresses: Vec<NoteAddr>) -> Vec<NoteAddr> {
123 addresses.sort_by_key(|address| {
124 (
125 address.part,
126 address.staff,
127 address.measure,
128 address.voice,
129 address.note,
130 )
131 });
132 addresses.dedup();
133 addresses
134}
135
136fn affected_addresses(before: &Score, after: &Score) -> Vec<NoteAddr> {
137 let before = note_records(before);
138 let after = note_records(after);
139 let ids: BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
140 let mut affected = Vec::new();
141 for id in ids {
142 let old = before.get(&id);
143 let new = after.get(&id);
144 if old != new {
145 affected.extend(
146 old.into_iter()
147 .flatten()
148 .map(|record| record.address.clone()),
149 );
150 affected.extend(
151 new.into_iter()
152 .flatten()
153 .map(|record| record.address.clone()),
154 );
155 }
156 }
157 sorted_unique_addresses(affected)
158}
159
160fn measure_structure(
161 score: &Score,
162 part: usize,
163 staff: usize,
164 measure: usize,
165) -> StructuralMeasureStructure {
166 let current = score
167 .parts
168 .get(part)
169 .and_then(|part_data| part_data.staves.get(staff))
170 .and_then(|staff_data| staff_data.measures.get(measure));
171 match current {
172 Some(current) => StructuralMeasureStructure {
173 part,
174 staff,
175 measure,
176 exists: true,
177 voice_note_counts: std::array::from_fn(|voice| current.voices[voice].len()),
178 source_voice_numbers: current.source_voice_numbers,
179 },
180 None => StructuralMeasureStructure {
181 part,
182 staff,
183 measure,
184 exists: false,
185 voice_note_counts: [0; 4],
186 source_voice_numbers: [None; 4],
187 },
188 }
189}
190
191fn resulting_measures(before: &Score, after: &Score) -> Vec<StructuralMeasureStructure> {
192 let mut result = Vec::new();
193 let part_count = before.parts.len().max(after.parts.len());
194 for part in 0..part_count {
195 let staff_count = before
196 .parts
197 .get(part)
198 .map(|part_data| part_data.staves.len())
199 .unwrap_or(0)
200 .max(
201 after
202 .parts
203 .get(part)
204 .map(|part_data| part_data.staves.len())
205 .unwrap_or(0),
206 );
207 for staff in 0..staff_count {
208 let measure_count = before
209 .parts
210 .get(part)
211 .and_then(|part_data| part_data.staves.get(staff))
212 .map(|staff_data| staff_data.measures.len())
213 .unwrap_or(0)
214 .max(
215 after
216 .parts
217 .get(part)
218 .and_then(|part_data| part_data.staves.get(staff))
219 .map(|staff_data| staff_data.measures.len())
220 .unwrap_or(0),
221 );
222 for measure in 0..measure_count {
223 let old = measure_structure(before, part, staff, measure);
224 let new = measure_structure(after, part, staff, measure);
225 if old != new {
226 result.push(new);
227 }
228 }
229 }
230 }
231 result
232}
233
234pub fn plan_structural_change(
240 score: &Score,
241 command: &Command,
242) -> Result<StructuralChangePlan, Error> {
243 if !is_structural_command(command) {
244 return Err(Error::InvalidCommand(
245 "structural change plans require a structural command".into(),
246 ));
247 }
248 let command_key = command_key(command);
249 let initial_validation = validate(score);
250 if !initial_validation.is_valid() {
251 return Ok(StructuralChangePlan {
252 contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
253 command_key,
254 can_apply: false,
255 affected_addresses: Vec::new(),
256 resulting_measures: Vec::new(),
257 diagnostics: vec![StructuralChangeDiagnostic {
258 kind: StructuralChangeDiagnosticKind::ValidationFailed,
259 message: format!(
260 "source score failed structural validation with {} error(s)",
261 initial_validation.errors.len()
262 ),
263 }],
264 });
265 }
266 let mut candidate = score.clone();
267 if let Err(error) = apply_command(command, &mut candidate) {
268 let message = error.to_string();
269 return Ok(StructuralChangePlan {
270 contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
271 command_key,
272 can_apply: false,
273 affected_addresses: Vec::new(),
274 resulting_measures: Vec::new(),
275 diagnostics: vec![StructuralChangeDiagnostic {
276 kind: diagnostic_kind(&message),
277 message,
278 }],
279 });
280 }
281 let validation = validate(&candidate);
282 if !validation.is_valid() {
283 return Ok(StructuralChangePlan {
284 contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
285 command_key,
286 can_apply: false,
287 affected_addresses: Vec::new(),
288 resulting_measures: Vec::new(),
289 diagnostics: vec![StructuralChangeDiagnostic {
290 kind: StructuralChangeDiagnosticKind::ValidationFailed,
291 message: format!(
292 "result would fail structural validation with {} error(s)",
293 validation.errors.len()
294 ),
295 }],
296 });
297 }
298 Ok(StructuralChangePlan {
299 contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
300 command_key,
301 can_apply: true,
302 affected_addresses: affected_addresses(score, &candidate),
303 resulting_measures: resulting_measures(score, &candidate),
304 diagnostics: Vec::new(),
305 })
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::{
312 Duration, DurationScale, ExplodeChordPitchesCmd, ExplodeVoicesCmd, ImplodeStavesCmd, Note,
313 Pitch, ScaleVoiceRangeCmd, ScoreTemplate, Step, TupletInfo, TupletScalePolicy,
314 };
315
316 fn piano_score() -> Score {
317 let mut score = Score::template(ScoreTemplate::Piano);
318 score.parts[0].staves[0].measures[0].voices[0] =
319 vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
320 score.parts[0].staves[1].measures[0].voices[0] =
321 vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
322 score
323 }
324
325 #[test]
326 fn implode_plan_is_non_mutating_and_describes_resulting_voices() {
327 let score = piano_score();
328 let before = serde_json::to_value(&score).unwrap();
329 let plan = plan_structural_change(
330 &score,
331 &Command::ImplodeStaves(ImplodeStavesCmd {
332 part_index: 0,
333 source_staves: vec![0, 1],
334 target_staff: 0,
335 start_measure: 0,
336 end_measure: 0,
337 }),
338 )
339 .unwrap();
340 assert!(plan.can_apply);
341 assert_eq!(plan.command_key, "ImplodeStaves");
342 assert!(plan.affected_addresses.iter().any(|address| {
343 address.part == 0
344 && address.staff == 1
345 && address.measure == 0
346 && address.voice == 0
347 && address.note == 0
348 }));
349 assert!(plan.affected_addresses.iter().any(|address| {
350 address.part == 0
351 && address.staff == 0
352 && address.measure == 0
353 && address.voice == 1
354 && address.note == 0
355 }));
356 assert!(plan.resulting_measures.iter().any(|measure| {
357 measure.part == 0
358 && measure.staff == 0
359 && measure.measure == 0
360 && measure.voice_note_counts == [1, 1, 0, 0]
361 }));
362 assert_eq!(serde_json::to_value(&score).unwrap(), before);
363 }
364
365 #[test]
366 fn explode_plan_reports_destination_conflict_without_mutation() {
367 let mut score = piano_score();
368 score.parts[0].staves[0].measures[0].voices[1] =
369 vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
370 let before = serde_json::to_value(&score).unwrap();
371 let plan = plan_structural_change(
372 &score,
373 &Command::ExplodeVoices(ExplodeVoicesCmd {
374 part_index: 0,
375 source_staff: 0,
376 target_staves: vec![0, 1],
377 start_measure: 0,
378 end_measure: 0,
379 }),
380 )
381 .unwrap();
382 assert!(!plan.can_apply);
383 assert_eq!(
384 plan.diagnostics[0].kind,
385 StructuralChangeDiagnosticKind::Conflict
386 );
387 assert_eq!(serde_json::to_value(&score).unwrap(), before);
388 }
389
390 #[test]
391 fn scale_plan_supports_tuplets_with_preserved_ratio() {
392 let mut score = piano_score();
393 score.parts[0].staves[0].measures[0].voices[0][0].tuplet = Some(TupletInfo {
394 actual_notes: 3,
395 normal_notes: 2,
396 });
397 let before = serde_json::to_value(&score).unwrap();
398 let plan = plan_structural_change(
399 &score,
400 &Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
401 part_index: 0,
402 staff_index: 0,
403 voice: 0,
404 start_measure: 0,
405 end_measure: 0,
406 scale: DurationScale::Half,
407 tuplet_policy: TupletScalePolicy::PreserveRatio,
408 }),
409 )
410 .unwrap();
411 assert!(plan.can_apply);
412 assert!(plan.diagnostics.is_empty());
413 assert_eq!(serde_json::to_value(&score).unwrap(), before);
414 }
415
416 #[test]
417 fn scale_plan_marks_duration_changes_at_stable_addresses() {
418 let score = piano_score();
419 let plan = plan_structural_change(
420 &score,
421 &Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
422 part_index: 0,
423 staff_index: 0,
424 voice: 0,
425 start_measure: 0,
426 end_measure: 0,
427 scale: DurationScale::Half,
428 tuplet_policy: TupletScalePolicy::PreserveRatio,
429 }),
430 )
431 .unwrap();
432 assert!(plan.can_apply);
433 assert!(plan.affected_addresses.iter().any(|address| {
434 address.part == 0
435 && address.staff == 0
436 && address.measure == 0
437 && address.voice == 0
438 && address.note == 0
439 }));
440 assert!(plan.resulting_measures.iter().any(|measure| {
441 measure.part == 0
442 && measure.staff == 0
443 && measure.measure == 0
444 && measure.voice_note_counts == [2, 0, 0, 0]
445 }));
446 }
447
448 #[test]
449 fn chord_explode_plan_is_available_before_mutation() {
450 let mut score = piano_score();
451 score.parts[0].staves[0].measures[0].voices[0][0]
452 .pitches
453 .push(Pitch::new(Step::E, 4));
454 score.parts[0].staves[1].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
455 let plan = plan_structural_change(
456 &score,
457 &Command::ExplodeChordPitches(ExplodeChordPitchesCmd {
458 part_index: 0,
459 source_staff: 0,
460 target_staves: vec![0, 1],
461 start_measure: 0,
462 end_measure: 0,
463 }),
464 )
465 .unwrap();
466 assert!(plan.can_apply);
467 assert!(plan.affected_addresses.iter().any(|address| {
468 address.part == 0
469 && address.staff == 1
470 && address.measure == 0
471 && address.voice == 0
472 && address.note == 0
473 }));
474 }
475}