Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 9 additions & 22 deletions core/crates/storage/src/connection_group_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@ use crate::name_rules::{self, NameFault};
pub struct ConnectionGroupName(String);

impl ConnectionGroupName {
/// Long enough for any real folder name, short enough for a menu.
pub const MAX_CHARS: usize = 64;

// No length limit: a macOS group name has none and has to read back unchanged.
pub fn new(value: &str) -> Result<Self, GroupNameError> {
match name_rules::checked(value, Self::MAX_CHARS) {
match name_rules::checked(value) {
Ok(name) => Ok(Self(name.to_owned())),
Err(NameFault::Empty) => Err(GroupNameError::Empty),
Err(NameFault::TooLong) => Err(GroupNameError::TooLong { max: Self::MAX_CHARS }),
Err(NameFault::ControlCharacter) => Err(GroupNameError::ControlCharacter),
}
}

/// A name read back from a file. A blank one means no group.
// A blank name means no group.
pub(crate) fn from_stored(value: &str) -> Option<Self> {
Self::new(&name_rules::cleaned(value, Self::MAX_CHARS)).ok()
Self::new(&name_rules::cleaned(value)).ok()
}

pub fn as_str(&self) -> &str {
Expand Down Expand Up @@ -76,15 +73,9 @@ mod tests {
use super::*;

#[test]
fn group_name_rejects_blank_long_and_control_characters() {
fn group_name_rejects_blank_and_control_characters() {
assert_eq!(ConnectionGroupName::new(""), Err(GroupNameError::Empty));
assert_eq!(ConnectionGroupName::new(" \t "), Err(GroupNameError::Empty));
assert_eq!(
ConnectionGroupName::new(&"a".repeat(ConnectionGroupName::MAX_CHARS + 1)),
Err(GroupNameError::TooLong {
max: ConnectionGroupName::MAX_CHARS
})
);
assert_eq!(
ConnectionGroupName::new("Work\nHome"),
Err(GroupNameError::ControlCharacter)
Expand All @@ -96,19 +87,15 @@ mod tests {
}

#[test]
fn the_limit_counts_characters_not_bytes() {
let name = "é".repeat(ConnectionGroupName::MAX_CHARS);
fn a_name_of_any_length_is_kept_whole() {
let name = "é".repeat(1_000);

assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name));
assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name.clone()));
assert_eq!(ConnectionGroupName::from_stored(&name).map(String::from), Some(name));
}

#[test]
fn a_stored_name_is_cleaned_rather_than_refused() {
let long = "a".repeat(ConnectionGroupName::MAX_CHARS + 10);
assert_eq!(
ConnectionGroupName::from_stored(&long).map(String::from),
Some("a".repeat(ConnectionGroupName::MAX_CHARS))
);
assert_eq!(
ConnectionGroupName::from_stored("Work\tHome\n").map(String::from),
Some(String::from("Work Home"))
Expand Down
21 changes: 15 additions & 6 deletions core/crates/storage/src/connection_tag_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ impl ConnectionTagName {
pub const MAX_CHARS: usize = 32;

pub fn new(value: &str) -> Result<Self, TagNameError> {
match name_rules::checked(value, Self::MAX_CHARS) {
match name_rules::checked(value) {
Ok(name) if name.chars().count() > Self::MAX_CHARS => Err(TagNameError::TooLong { max: Self::MAX_CHARS }),
Ok(name) => Ok(Self(name.to_owned())),
Err(NameFault::Empty) => Err(TagNameError::Empty),
Err(NameFault::TooLong) => Err(TagNameError::TooLong { max: Self::MAX_CHARS }),
Err(NameFault::ControlCharacter) => Err(TagNameError::ControlCharacter),
}
}
Expand All @@ -36,15 +36,17 @@ impl ConnectionTagName {
}
}

/// A tag's name read back from a file, cleaned as a group's is. A name
/// left blank is refused: a tag nobody can see cannot be told apart.
// A blank name is refused: a tag nobody can see cannot be told apart.
pub(crate) fn deserialize_stored<'de, D>(deserializer: D) -> Result<ConnectionTagName, D::Error>
where
D: Deserializer<'de>,
{
let stored = String::deserialize(deserializer)?;
ConnectionTagName::new(&name_rules::cleaned(&stored, ConnectionTagName::MAX_CHARS))
.map_err(serde::de::Error::custom)
let cut: String = name_rules::cleaned(&stored)
.chars()
.take(ConnectionTagName::MAX_CHARS)
.collect();
ConnectionTagName::new(&cut).map_err(serde::de::Error::custom)
}

impl TryFrom<String> for ConnectionTagName {
Expand Down Expand Up @@ -87,6 +89,13 @@ mod tests {
assert_eq!(ConnectionTagName::new("a\u{7}"), Err(TagNameError::ControlCharacter));
}

#[test]
fn the_limit_counts_characters_not_bytes() {
let name = "é".repeat(ConnectionTagName::MAX_CHARS);

assert_eq!(ConnectionTagName::new(&name).map(String::from), Ok(name));
}

#[test]
fn names_that_differ_only_in_case_are_the_same_tag() {
let name = ConnectionTagName::new("Production").expect("a name");
Expand Down
9 changes: 3 additions & 6 deletions core/crates/storage/src/connections/legacy_groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,16 @@ mod tests {
}

#[test]
fn a_name_too_long_or_with_control_characters_is_cleaned_first() {
let long = "g".repeat(ConnectionGroupName::MAX_CHARS + 5);
fn a_long_name_is_kept_and_control_characters_are_cleaned_first() {
let long = "g".repeat(200);
let document = upgraded(&format!(
r#"[{{"name": "a", "group": "{long}"}}, {{"name": "b", "group": "Work\tHome"}}]"#
));

let names: Vec<&str> = (0..2)
.map(|at| document["groups"][at]["name"].as_str().unwrap_or_default())
.collect();
assert_eq!(
names,
["g".repeat(ConnectionGroupName::MAX_CHARS).as_str(), "Work Home"]
);
assert_eq!(names, [long.as_str(), "Work Home"]);
}

#[test]
Expand Down
9 changes: 3 additions & 6 deletions core/crates/storage/src/connections/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1965,13 +1965,10 @@ mod tests {
}

#[test]
fn a_group_an_earlier_build_wrote_past_the_limits_still_loads() {
let long = "g".repeat(crate::ConnectionGroupName::MAX_CHARS + 1);
fn a_long_group_loads_whole_and_control_characters_are_cleaned() {
let long = "g".repeat(200);
for (stored, read) in [
(
format!(r#""{long}""#),
"g".repeat(crate::ConnectionGroupName::MAX_CHARS),
),
(format!(r#""{long}""#), long.clone()),
(String::from(r#""Work\tHome""#), String::from("Work Home")),
] {
let root = TempDir::new().expect("tempdir");
Expand Down
10 changes: 2 additions & 8 deletions core/crates/storage/src/group_name_error.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
/// Why a group name cannot be used. The messages are also what a
/// connection list with such a name on disk is refused with.
// The messages are also what a connection list with such a name on disk is refused with.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GroupNameError {
#[error("a group name cannot be empty")]
Empty,
#[error("a group name can be at most {max} characters long")]
TooLong { max: usize },
#[error("a group name cannot contain control characters")]
ControlCharacter,
}

impl GroupNameError {
/// A name for the variant that stays the same across releases, for a
/// UI outside Rust to word the error from.
// Stable across releases, for a UI outside Rust to word the error from.
pub fn code(&self) -> &'static str {
match self {
Self::Empty => "group_name.empty",
Self::TooLong { .. } => "group_name.too_long",
Self::ControlCharacter => "group_name.control_character",
}
}
Expand All @@ -29,7 +24,6 @@ mod tests {
#[test]
fn each_name_error_has_a_code_of_its_own() {
assert_eq!(GroupNameError::Empty.code(), "group_name.empty");
assert_eq!(GroupNameError::TooLong { max: 64 }.code(), "group_name.too_long");
assert_eq!(GroupNameError::ControlCharacter.code(), "group_name.control_character");
}
}
33 changes: 11 additions & 22 deletions core/crates/storage/src/name_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,24 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NameFault {
Empty,
TooLong,
ControlCharacter,
}

/// `value` without the spaces around it, when it is a name a menu, a row
/// and a section title can all show.
pub(crate) fn checked(value: &str, max: usize) -> Result<&str, NameFault> {
pub(crate) fn checked(value: &str) -> Result<&str, NameFault> {
let name = value.trim();
if name.is_empty() {
return Err(NameFault::Empty);
}
if name.chars().count() > max {
return Err(NameFault::TooLong);
}
if name.chars().any(char::is_control) {
return Err(NameFault::ControlCharacter);
}
Ok(name)
}

/// A name read back from a file, with control characters turned into
/// spaces and cut to the limit, since a file is never refused for what
/// an earlier build let the user type.
pub(crate) fn cleaned(value: &str, max: usize) -> String {
// A file is never refused for what an earlier build let the user type.
pub(crate) fn cleaned(value: &str) -> String {
let shown: String = value.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
shown.trim().chars().take(max).collect()
shown.trim().to_owned()
}

/// A connection's name as the list shows it: control characters as
Expand Down Expand Up @@ -71,18 +63,15 @@ mod tests {
}

#[test]
fn a_name_is_trimmed_and_checked_in_characters() {
assert_eq!(checked(" Work ", 4), Ok("Work"));
assert_eq!(checked("éééé", 4), Ok("éééé"));
assert_eq!(checked("ééééé", 4), Err(NameFault::TooLong));
assert_eq!(checked(" \t ", 4), Err(NameFault::Empty));
assert_eq!(checked("a\nb", 4), Err(NameFault::ControlCharacter));
fn a_name_is_trimmed_and_checked() {
assert_eq!(checked(" Work "), Ok("Work"));
assert_eq!(checked(" \t "), Err(NameFault::Empty));
assert_eq!(checked("a\nb"), Err(NameFault::ControlCharacter));
}

#[test]
fn a_stored_name_loses_control_characters_and_the_excess() {
assert_eq!(cleaned("Work\tHome\n", 64), "Work Home");
assert_eq!(cleaned("abcdef", 4), "abcd");
assert_eq!(cleaned(" \u{7} ", 4), "");
fn a_stored_name_loses_control_characters() {
assert_eq!(cleaned("Work\tHome\n"), "Work Home");
assert_eq!(cleaned(" \u{7} "), "");
}
}
2 changes: 1 addition & 1 deletion core/docs/decisions/0006-connection-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The saved-connection list in `tablepro-storage` filed each connection under a gr
| Bundle format 1 (`groupName`) | a top-level group per name, as the version 2 upgrade does; the core does not read bundles yet |
| Bundle format 2 (`groups` with `ref` and `parentRef`, `groupRef`) | the same tree; a bundle carries no order or favourites |

What the core cannot hold exactly: a group name over 64 characters or with control characters is cut and cleaned as a Linux one is, and the core's `Teal` has no macOS colour. Linked folders and the team library are not stored in the library, so they stay outside it.
What the core cannot hold exactly: a group name with control characters is cleaned as a Linux one is, and the core's `Teal` has no macOS colour. Linked folders and the team library are not stored in the library, so they stay outside it.

## Alternatives considered

Expand Down
1 change: 1 addition & 0 deletions linux/changelog.d/3994.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Connection group names can be longer than 64 characters.
2 changes: 1 addition & 1 deletion linux/crates/app/src/ui/picker/new_group_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ mod tests {
("work", false),
("WORK", false),
("Archive", true),
(&"x".repeat(ConnectionGroupName::MAX_CHARS + 1), false),
(&"x".repeat(200), true),
("Staging", true),
("work / staging", false),
] {
Expand Down
4 changes: 2 additions & 2 deletions linux/docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ The app's data goes to the store its shape calls for. The core's `storage` crate
- `external_access` is what MCP clients may do on the connection: `blocked`, `read_only` or `read_write` ([ADR 0022](decisions/0022-ai-assistant-and-mcp-server.md)). A file written before it existed reads as `read_only`, the level a new connection starts at.
- `ai_policy` is what the AI assistant may send from the connection: `ask`, `allow` or `never`. A file written before it existed reads as `ask`, the policy a new connection starts at.
- `query_timeout_secs` is the editor's query timeout on the connection, in seconds, with 0 for none. Left out, as in a file written before it existed, the connection follows the timeout in Preferences, and it is written back without one.
- The file's `groups` list is the library's folders, a tree ([core ADR 0006](../../core/docs/decisions/0006-connection-library.md)). Each `ConnectionGroup` has an `id`, a `name` (`ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters, cleaned on read as a tag's is), a `parent_id` (none for a top-level group), an `order` among its siblings, and an optional `color` and `icon` (`GroupIcon`: one of the `ConnectionIcon` names, or the SF Symbol a macOS group wears, which Linux does not draw). Groups nest at most 3 levels. A new or moved group may not share a name with a sibling in any case; a list upgraded from version 2 can still hold two that differ only in case.
- The file's `groups` list is the library's folders, a tree ([core ADR 0006](../../core/docs/decisions/0006-connection-library.md)). Each `ConnectionGroup` has an `id`, a `name` (`ConnectionGroupName`: trimmed, not empty, of any length as on macOS, no control characters, which a read turns into spaces), a `parent_id` (none for a top-level group), an `order` among its siblings, and an optional `color` and `icon` (`GroupIcon`: one of the `ConnectionIcon` names, or the SF Symbol a macOS group wears, which Linux does not draw). Groups nest at most 3 levels. A new or moved group may not share a name with a sibling in any case; a list upgraded from version 2 can still hold two that differ only in case.
- A connection's `group_id` names its group, none for the top of the list, and its `order` is its place there. Every write numbers each group's groups and connections from 0, so a new entry, or one moved to another group, goes last there. A read roots a group whose parent is missing or that sits on a cycle, keeps the first of two entries with one id, and moves a connection whose group is missing to the top.
- `create_group_blocking`, `rename_group_blocking`, `set_group_color_blocking`, `set_group_icon_blocking`, `move_groups_blocking` (to another parent, before a sibling), `delete_group_blocking`, `move_connections_blocking` (to a group, before a connection) and `move_connections_to_group_named_blocking` change the tree. Each refuses what would break it with a `StorageError` carrying a stable code: `GroupNotFound`, `GroupExists`, `GroupNameTaken`, `GroupCycle`, `GroupTooDeep`, `ConnectionNotFound`. Deleting a group deletes the groups inside it and moves their connections to the top of the list.
- `favorites` lists the favourite connections in their order. `add_favorites_blocking` adds or moves some before a given one, `remove_favorites_blocking` takes them off, and deleting a connection drops it. `ConnectionList::recent` is the opened connections, newest first, without the favourites.
- The Linux app shows a group by its path from the top, such as `Work / Staging`, in the picker's sections, the window's tree, the search and every Move to Group menu, and sorts groups by that path. New Group, and a Move to Group pick of a name the list does not show, file the connection under the top-level group of that name, made when none has it in any case. A group with no connection is not shown.
- Version 2 filed a connection under a `group` name. Its upgrade makes each name a top-level group whose id comes from the name, so identical names share one group and a read before the first save gives the same ids; names are cleaned as above, and a blank one is no group. Groups and each group's connections are numbered by name.
- The file's `tags` list holds every `ConnectionTag`: an `id`, a `name` (`ConnectionTagName`: trimmed, 1 to 32 characters, no control characters, cleaned on read as a group's is) and a `color` from the same palette as `color`. No two tags share a name in any case: `create_tag_blocking` and `update_tag_blocking` refuse one with `TagNameTaken`.
- The file's `tags` list holds every `ConnectionTag`: an `id`, a `name` (`ConnectionTagName`: trimmed, 1 to 32 characters, no control characters, cleaned on read as a group's is and cut to 32) and a `color` from the same palette as `color`. No two tags share a name in any case: `create_tag_blocking` and `update_tag_blocking` refuse one with `TagNameTaken`.
- A connection's `tags` are ids from that list. Every write drops an id the list lacks and a repeated one, so `delete_tag_blocking` takes the tag off every connection in the same write, and a connection saved with a tag deleted meanwhile loses it.
- `icon` is one of the `ConnectionIcon` names. The app draws each with a symbolic icon from the theme (`crates/app/src/ui/icons.rs`).
- A connection's `name` is stored and compared as the list shows it: control characters as spaces, characters that show as nothing (U+200B, U+FEFF) dropped, no blanks around it, so a name that looks like a saved one is the same name.
Expand Down
Loading