logisheets_controller 1.16.1

Core spreadsheet engine for LogiSheets: formula evaluation, dependency tracking, and undo/redo.
Documentation
use imbl::Vector;
use logisheets_base::{BlockId, ColId, NormalCellId, RowId};

use crate::block_manager::schema_manager::field_type::PivotSpec;
use crate::edit_action::{BlockPermissions, ModifyPolicy};

#[derive(Debug, Clone)]
pub struct BlockPlace {
    pub master: NormalCellId,
    pub rows: Vector<RowId>,
    pub cols: Vector<ColId>,
    next_avail_row: RowId,
    next_avail_col: ColId,
    pub owner: String,
    /// The block's default write policy: what an operation with no explicit
    /// entry in `permissions` falls back to.
    pub modify_policy: ModifyPolicy,
    /// What this block is for, in prose. Written for an AI reading the sheet:
    /// a block's schema says what shape its records are, and nothing else says
    /// what they mean or how they are meant to be used.
    pub description: String,
    /// Per-operation overrides of `modify_policy`. Empty — which is every
    /// block loaded from a file written before this existed — means the single
    /// policy governs everything, exactly as it used to.
    pub permissions: BlockPermissions,
    /// Which block this one ANALYSES, when it is an analysis block: a total
    /// row, a set of statistics, later a pivot.
    ///
    /// An analysis block is an ordinary block — cells, schema, fields,
    /// persistence, governance, all the same. This marker is the whole of what
    /// makes it one, and it deliberately says nothing about shape: a total row
    /// happens to have its source's columns, a pivot's columns are the distinct
    /// values of one of them. That is what lets one model carry both.
    ///
    /// Same sheet only — `BlockId` is unique within a sheet, so no sheet id is
    /// needed and a cross-sheet analysis is not expressible.
    ///
    /// Read for an AI first: `describe_block` reports it in both directions, so
    /// an agent reading the sheet sees "a table and its analysis" rather than
    /// two unrelated tables — and cannot mistake a total for a record. See
    /// `design/block-analysis.md`.
    pub analyzes: Option<BlockId>,
    /// When this analysis block is a PIVOT: the recipe its cells and its shape
    /// are both derived from. `None` — the ordinary case, and every analysis
    /// block that exists today — is a total row: fields declare their own
    /// aggregates and the shape is fixed.
    ///
    /// Beside `analyzes` rather than inside it because the two answer different
    /// questions: `analyzes` says WHICH block, `pivot` says what shape of
    /// analysis. A pivot without `analyzes` is meaningless and refused at
    /// bind time. See `design/block-pivot.md`.
    pub pivot: Option<PivotSpec>,
}

impl BlockPlace {
    pub fn new(
        master: NormalCellId,
        row_cnt: u32,
        col_cnt: u32,
        owner: String,
        modify_policy: ModifyPolicy,
    ) -> Self {
        let rows = (0..row_cnt).collect::<Vector<RowId>>();
        let cols = (0..col_cnt).collect::<Vector<ColId>>();
        let next_avail_row = row_cnt as RowId;
        let next_avail_col = col_cnt as ColId;
        BlockPlace {
            master,
            rows,
            cols,
            next_avail_row,
            next_avail_col,
            owner,
            modify_policy,
            description: String::new(),
            permissions: BlockPermissions::default(),
            analyzes: None,
            pivot: None,
        }
    }

    /// Declare which block this one analyses. Separate from [`Self::new`] for
    /// the same reason the description is: an analysis block is created and
    /// then told what it is for.
    pub fn with_analyzes(mut self, source: Option<BlockId>) -> Self {
        self.analyzes = source;
        self
    }

    /// Declare the pivot recipe. Separate from [`Self::with_analyzes`] because
    /// a pivot is first an analysis block and only then a pivot — and because
    /// the recipe can be changed later without touching what it analyses.
    pub fn with_pivot(mut self, pivot: Option<PivotSpec>) -> Self {
        self.pivot = pivot;
        self
    }

    /// Replace the prose description. Separate from [`Self::new`] because a
    /// block is usually created before anyone has anything to say about it.
    pub fn with_description(mut self, description: String) -> Self {
        self.description = description;
        self
    }

    pub fn with_permissions(mut self, permissions: BlockPermissions) -> Self {
        self.permissions = permissions;
        self
    }

    pub fn resize(self, row_cnt: Option<usize>, col_cnt: Option<usize>) -> Self {
        let mut result = self;

        let row_cnt = row_cnt.unwrap_or(result.rows.len());
        let col_cnt = col_cnt.unwrap_or(result.cols.len());
        let rows = if result.rows.len() >= row_cnt {
            result.rows.take(row_cnt)
        } else {
            let mut new_rows = result.rows;
            while new_rows.len() < row_cnt {
                new_rows.push_back(result.next_avail_row);
                result.next_avail_row += 1;
            }
            new_rows
        };
        let cols = if result.cols.len() >= col_cnt {
            result.cols.take(col_cnt)
        } else {
            let mut new_cols = result.cols;
            while new_cols.len() < col_cnt {
                new_cols.push_back(result.next_avail_col);
                result.next_avail_col += 1;
            }
            new_cols
        };
        BlockPlace {
            master: result.master,
            rows,
            cols,
            next_avail_row: result.next_avail_row,
            next_avail_col: result.next_avail_col,
            owner: result.owner,
            modify_policy: result.modify_policy,
            description: result.description,
            permissions: result.permissions,
            analyzes: result.analyzes,
            pivot: result.pivot,
        }
    }

    pub fn add_new_rows(self, idx: usize, cnt: u32) -> Self {
        let new_next_avail_row = self.next_avail_row + cnt;
        let new_row_ids = (self.next_avail_row..new_next_avail_row)
            .into_iter()
            .collect::<Vector<_>>();
        let (mut left, right) = self.rows.split_at(idx);
        left.append(new_row_ids);
        left.append(right);
        BlockPlace {
            master: self.master,
            rows: left,
            cols: self.cols,
            next_avail_row: new_next_avail_row,
            next_avail_col: self.next_avail_col,
            owner: self.owner,
            modify_policy: self.modify_policy,
            description: self.description,
            permissions: self.permissions,
            analyzes: self.analyzes,
            pivot: self.pivot,
        }
    }

    pub fn add_new_cols(self, idx: usize, cnt: u32) -> Self {
        let new_next_avail_col = self.next_avail_col + cnt;
        let new_col_ids = (self.next_avail_col..new_next_avail_col)
            .into_iter()
            .collect::<Vector<_>>();
        let (mut left, right) = self.cols.split_at(idx);
        left.append(new_col_ids);
        left.append(right);
        BlockPlace {
            master: self.master,
            rows: self.rows,
            cols: left,
            next_avail_row: self.next_avail_row,
            next_avail_col: new_next_avail_col,
            owner: self.owner,
            modify_policy: self.modify_policy,
            description: self.description,
            permissions: self.permissions,
            analyzes: self.analyzes,
            pivot: self.pivot,
        }
    }

    pub fn delete_rows(self, idx: usize, cnt: u32) -> Self {
        let (mut left, right) = self.rows.split_at(idx);
        let (_, right) = right.split_at(cnt as usize);
        left.append(right);
        BlockPlace {
            master: self.master,
            rows: left,
            cols: self.cols,
            next_avail_row: self.next_avail_row,
            next_avail_col: self.next_avail_col,
            owner: self.owner,
            modify_policy: self.modify_policy,
            description: self.description,
            permissions: self.permissions,
            analyzes: self.analyzes,
            pivot: self.pivot,
        }
    }

    pub fn delete_cols(self, idx: usize, cnt: u32) -> Self {
        let (mut left, right) = self.cols.split_at(idx);
        let (_, right) = right.split_at(cnt as usize);
        left.append(right);
        BlockPlace {
            master: self.master,
            rows: self.rows,
            cols: left,
            next_avail_row: self.next_avail_row,
            next_avail_col: self.next_avail_col,
            owner: self.owner,
            modify_policy: self.modify_policy,
            description: self.description,
            permissions: self.permissions,
            analyzes: self.analyzes,
            pivot: self.pivot,
        }
    }

    pub fn get_inner_id(&self, row: usize, col: usize) -> Option<(RowId, ColId)> {
        let rid = self.rows.get(row)?.clone();
        let cid = self.cols.get(col)?.clone();
        Some((rid, cid))
    }

    pub fn get_inner_idx(&self, row: RowId, col: ColId) -> Option<(usize, usize)> {
        let (ridx, _rid) = self.rows.iter().enumerate().find(|(_, r)| **r == row)?;
        let (cidx, _cid) = self.cols.iter().enumerate().find(|(_, c)| **c == col)?;
        Some((ridx, cidx))
    }

    pub fn get_block_size(&self) -> (usize, usize) {
        (self.rows.len(), self.cols.len())
    }

    pub fn move_line(self, from: usize, to: usize, is_row: bool) -> Self {
        let mut result = self;
        if is_row {
            let item = result.rows.remove(from);
            result.rows.insert(to, item);
        } else {
            let item = result.cols.remove(from);
            result.cols.insert(to, item);
        }
        result
    }

    pub fn reorder_lines(self, new_order: &[usize], is_row: bool) -> Self {
        let mut result = self;
        if is_row {
            let old = result.rows.clone();
            result.rows = new_order.iter().map(|&i| old[i]).collect();
        } else {
            let old = result.cols.clone();
            result.cols = new_order.iter().map(|&i| old[i]).collect();
        }
        result
    }
}

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

    fn dummy_block_place(rows: Vector<RowId>, cols: Vector<ColId>) -> BlockPlace {
        BlockPlace {
            master: NormalCellId { row: 0, col: 0 },
            rows,
            cols,
            next_avail_row: 0,
            next_avail_col: 0,
            owner: String::new(),
            modify_policy: ModifyPolicy::All,
            description: String::new(),
            permissions: BlockPermissions::default(),
            analyzes: None,
            pivot: None,
        }
    }

    #[test]
    fn test_move_line_row_down() {
        let bp = dummy_block_place(Vector::from(vec![0, 1, 2, 3]), Vector::new());
        let bp = bp.move_line(0, 2, true);
        assert_eq!(bp.rows, Vector::from(vec![1, 2, 0, 3]));
    }

    #[test]
    fn test_move_line_row_up() {
        let bp = dummy_block_place(Vector::from(vec![0, 1, 2, 3]), Vector::new());
        let bp = bp.move_line(3, 1, true);
        assert_eq!(bp.rows, Vector::from(vec![0, 3, 1, 2]));
    }

    #[test]
    fn test_move_line_col() {
        let bp = dummy_block_place(Vector::new(), Vector::from(vec![10, 20, 30, 40]));
        let bp = bp.move_line(1, 3, false);
        assert_eq!(bp.cols, Vector::from(vec![10, 30, 40, 20]));
    }

    #[test]
    fn test_reorder_lines_row() {
        let bp = dummy_block_place(Vector::from(vec![0, 1, 2, 3]), Vector::new());
        let bp = bp.reorder_lines(&[3, 0, 2, 1], true);
        assert_eq!(bp.rows, Vector::from(vec![3, 0, 2, 1]));
    }

    #[test]
    fn test_reorder_lines_col() {
        let bp = dummy_block_place(Vector::new(), Vector::from(vec![10, 20, 30, 40]));
        let bp = bp.reorder_lines(&[2, 0, 3, 1], false);
        assert_eq!(bp.cols, Vector::from(vec![30, 10, 40, 20]));
    }
}