aven 0.1.11

Local-first task manager CLI and sync server
Documentation
use crate::tui::store::ConflictTarget;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConflictResolutionChoice {
    Local,
    Remote,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ConflictFlow {
    PickVariant {
        choice: ConflictResolutionChoice,
        targets: Vec<ConflictTarget>,
    },
    ConfirmVariant {
        choice: ConflictResolutionChoice,
        target: ConflictTarget,
    },
    PickManual {
        targets: Vec<ConflictTarget>,
    },
    EditManual {
        target: ConflictTarget,
    },
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ConflictFlowState {
    flow: Option<ConflictFlow>,
}

pub(crate) enum ConflictTransition {
    PickField {
        targets: Vec<ConflictTarget>,
    },
    Confirm {
        choice: ConflictResolutionChoice,
        target: ConflictTarget,
    },
    EditManual {
        target: ConflictTarget,
    },
    Message(String),
}

pub(crate) enum ConflictSubmit {
    Resolve {
        target: ConflictTarget,
        value: String,
    },
    Inactive {
        message: &'static str,
    },
}

impl ConflictFlowState {
    pub(crate) fn begin_resolution(
        &mut self,
        choice: ConflictResolutionChoice,
        targets: Vec<ConflictTarget>,
    ) -> ConflictTransition {
        if targets.len() == 1 {
            let target = targets[0].clone();
            self.flow = Some(ConflictFlow::ConfirmVariant {
                choice,
                target: target.clone(),
            });
            ConflictTransition::Confirm { choice, target }
        } else {
            self.flow = Some(ConflictFlow::PickVariant {
                choice,
                targets: targets.clone(),
            });
            ConflictTransition::PickField { targets }
        }
    }

    pub(crate) fn begin_manual(&mut self, targets: Vec<ConflictTarget>) -> ConflictTransition {
        if targets.len() == 1 {
            let target = targets[0].clone();
            self.flow = Some(ConflictFlow::EditManual {
                target: target.clone(),
            });
            ConflictTransition::EditManual { target }
        } else {
            self.flow = Some(ConflictFlow::PickManual {
                targets: targets.clone(),
            });
            ConflictTransition::PickField { targets }
        }
    }

    pub(crate) fn submit_field(&mut self, values: Vec<String>) -> ConflictTransition {
        let Some(field) = values.first().filter(|value| !value.is_empty()).cloned() else {
            return ConflictTransition::Message("no conflict field selected".to_string());
        };
        match self.flow.take() {
            Some(ConflictFlow::PickVariant { choice, targets }) => {
                let Some(target) = targets.into_iter().find(|target| target.field == field) else {
                    return ConflictTransition::Message(format!("no conflict for field={field}"));
                };
                self.flow = Some(ConflictFlow::ConfirmVariant {
                    choice,
                    target: target.clone(),
                });
                ConflictTransition::Confirm { choice, target }
            }
            Some(ConflictFlow::PickManual { targets }) => {
                let Some(target) = targets.into_iter().find(|target| target.field == field) else {
                    return ConflictTransition::Message(format!("no conflict for field={field}"));
                };
                self.flow = Some(ConflictFlow::EditManual {
                    target: target.clone(),
                });
                ConflictTransition::EditManual { target }
            }
            _ => ConflictTransition::Message("conflict field picker is not active".to_string()),
        }
    }

    pub(crate) fn submit_confirmed_variant(&mut self) -> ConflictSubmit {
        let Some(ConflictFlow::ConfirmVariant { choice, target }) = self.flow.take() else {
            return ConflictSubmit::Inactive {
                message: "conflict confirmation is not active",
            };
        };
        let value = match choice {
            ConflictResolutionChoice::Local => target.local_value.clone(),
            ConflictResolutionChoice::Remote => target.remote_value.clone(),
        };
        ConflictSubmit::Resolve { target, value }
    }

    pub(crate) fn submit_manual_value(&mut self, value: String) -> ConflictSubmit {
        let Some(ConflictFlow::EditManual { target }) = self.flow.take() else {
            return ConflictSubmit::Inactive {
                message: "manual conflict edit is not active",
            };
        };
        ConflictSubmit::Resolve { target, value }
    }

    pub(crate) fn retry_manual_edit(&mut self, target: ConflictTarget) -> ConflictTransition {
        self.flow = Some(ConflictFlow::EditManual {
            target: target.clone(),
        });
        ConflictTransition::EditManual { target }
    }

    pub(crate) fn clear(&mut self) {
        self.flow = None;
    }

    #[cfg(test)]
    pub(crate) fn is_active(&self) -> bool {
        self.flow.is_some()
    }

    #[cfg(test)]
    pub(crate) fn is_idle(&self) -> bool {
        self.flow.is_none()
    }
}

pub(crate) fn truncate_value_preview(value: &str, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        return value.to_string();
    }
    let truncated: String = value.chars().take(max_chars).collect();
    format!("{truncated}")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_conflict_target(field: &str) -> ConflictTarget {
        ConflictTarget {
            task_id: "task-1".to_string(),
            display_ref: "APP-1234".to_string(),
            field: field.to_string(),
            variant_a: "local".to_string(),
            local_value: "local value".to_string(),
            variant_b: "remote".to_string(),
            remote_value: "remote value".to_string(),
        }
    }

    #[test]
    fn inactive_confirm_reports_existing_message() {
        let mut state = ConflictFlowState::default();
        assert!(matches!(
            state.submit_confirmed_variant(),
            ConflictSubmit::Inactive {
                message: "conflict confirmation is not active"
            }
        ));
    }

    #[test]
    fn field_picker_unknown_field_reports_message() {
        let target = test_conflict_target("title");
        let other = test_conflict_target("description");
        let mut state = ConflictFlowState::default();
        state.begin_resolution(ConflictResolutionChoice::Local, vec![target, other]);
        assert!(matches!(
            state.submit_field(vec!["missing".to_string()]),
            ConflictTransition::Message(message) if message == "no conflict for field=missing"
        ));
    }

    #[test]
    fn begin_variant_single_target_skips_field_picker() {
        let target = test_conflict_target("title");
        let mut state = ConflictFlowState::default();
        assert!(matches!(
            state.begin_resolution(ConflictResolutionChoice::Local, vec![target.clone()]),
            ConflictTransition::Confirm {
                choice: ConflictResolutionChoice::Local,
                ..
            }
        ));
        assert!(state.is_active());
    }

    #[test]
    fn manual_submit_inactive_returns_manual_conflict_edit_is_not_active() {
        let mut state = ConflictFlowState::default();
        assert!(matches!(
            state.submit_manual_value("value".to_string()),
            ConflictSubmit::Inactive {
                message: "manual conflict edit is not active"
            }
        ));
    }

    #[test]
    fn truncate_value_preview_uses_character_count() {
        assert_eq!(truncate_value_preview("abc", 5), "abc");
        assert_eq!(truncate_value_preview("abcdef", 3), "abc…");
    }
}