diff --git a/milli/src/prompt/mod.rs b/milli/src/prompt/mod.rs index 47f949ea5..3b32b916f 100644 --- a/milli/src/prompt/mod.rs +++ b/milli/src/prompt/mod.rs @@ -6,6 +6,7 @@ mod template_checker; use std::collections::BTreeMap; use std::convert::TryFrom; +use std::num::NonZeroUsize; use std::ops::Deref; use error::{NewPromptError, RenderPromptError}; @@ -18,16 +19,18 @@ use crate::{FieldId, FieldsIdsMap}; pub struct Prompt { template: liquid::Template, template_text: String, + max_bytes: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PromptData { pub template: String, + pub max_bytes: Option, } impl From for PromptData { fn from(value: Prompt) -> Self { - Self { template: value.template_text } + Self { template: value.template_text, max_bytes: value.max_bytes } } } @@ -35,14 +38,18 @@ impl TryFrom for Prompt { type Error = NewPromptError; fn try_from(value: PromptData) -> Result { - Prompt::new(value.template) + Prompt::new(value.template, value.max_bytes) } } impl Clone for Prompt { fn clone(&self) -> Self { let template_text = self.template_text.clone(); - Self { template: new_template(&template_text).unwrap(), template_text } + Self { + template: new_template(&template_text).unwrap(), + template_text, + max_bytes: self.max_bytes, + } } } @@ -62,20 +69,28 @@ fn default_template_text() -> &'static str { {% endfor %}" } +pub fn default_max_bytes() -> NonZeroUsize { + NonZeroUsize::new(400).unwrap() +} + impl Default for Prompt { fn default() -> Self { - Self { template: default_template(), template_text: default_template_text().into() } + Self { + template: default_template(), + template_text: default_template_text().into(), + max_bytes: Some(default_max_bytes()), + } } } impl Default for PromptData { fn default() -> Self { - Self { template: default_template_text().into() } + Self { template: default_template_text().into(), max_bytes: Some(default_max_bytes()) } } } impl Prompt { - pub fn new(template: String) -> Result { + pub fn new(template: String, max_bytes: Option) -> Result { let this = Self { template: liquid::ParserBuilder::with_stdlib() .build() @@ -83,6 +98,7 @@ impl Prompt { .parse(&template) .map_err(NewPromptError::cannot_parse_template)?, template_text: template, + max_bytes, }; // render template with special object that's OK with `doc.*` and `fields.*` @@ -102,7 +118,24 @@ impl Prompt { let document = Document::new(document, side, field_id_map); let context = Context::new(&document, field_id_map); - self.template.render(&context).map_err(RenderPromptError::missing_context) + let mut rendered = + self.template.render(&context).map_err(RenderPromptError::missing_context)?; + if let Some(max_bytes) = self.max_bytes { + truncate(&mut rendered, max_bytes.get()); + } + Ok(rendered) + } +} + +fn truncate(s: &mut String, max_bytes: usize) { + if max_bytes >= s.len() { + return; + } + for i in (0..=max_bytes).rev() { + if s.is_char_boundary(i) { + s.truncate(i); + break; + } } } @@ -145,6 +178,7 @@ mod test { use super::Prompt; use crate::error::FaultSource; use crate::prompt::error::{NewPromptError, NewPromptErrorKind}; + use crate::prompt::truncate; #[test] fn default_template() { @@ -154,18 +188,18 @@ mod test { #[test] fn empty_template() { - Prompt::new("".into()).unwrap(); + Prompt::new("".into(), None).unwrap(); } #[test] fn template_ok() { - Prompt::new("{{doc.title}}: {{doc.overview}}".into()).unwrap(); + Prompt::new("{{doc.title}}: {{doc.overview}}".into(), None).unwrap(); } #[test] fn template_syntax() { assert!(matches!( - Prompt::new("{{doc.title: {{doc.overview}}".into()), + Prompt::new("{{doc.title: {{doc.overview}}".into(), None), Err(NewPromptError { kind: NewPromptErrorKind::CannotParseTemplate(_), fault: FaultSource::User @@ -176,7 +210,7 @@ mod test { #[test] fn template_missing_doc() { assert!(matches!( - Prompt::new("{{title}}: {{overview}}".into()), + Prompt::new("{{title}}: {{overview}}".into(), None), Err(NewPromptError { kind: NewPromptErrorKind::InvalidFieldsInTemplate(_), fault: FaultSource::User @@ -186,29 +220,62 @@ mod test { #[test] fn template_nested_doc() { - Prompt::new("{{doc.actor.firstName}}: {{doc.actor.lastName}}".into()).unwrap(); + Prompt::new("{{doc.actor.firstName}}: {{doc.actor.lastName}}".into(), None).unwrap(); } #[test] fn template_fields() { - Prompt::new("{% for field in fields %}{{field}}{% endfor %}".into()).unwrap(); + Prompt::new("{% for field in fields %}{{field}}{% endfor %}".into(), None).unwrap(); } #[test] fn template_fields_ok() { - Prompt::new("{% for field in fields %}{{field.name}}: {{field.value}}{% endfor %}".into()) - .unwrap(); + Prompt::new( + "{% for field in fields %}{{field.name}}: {{field.value}}{% endfor %}".into(), + None, + ) + .unwrap(); } #[test] fn template_fields_invalid() { assert!(matches!( // intentionally garbled field - Prompt::new("{% for field in fields %}{{field.vaelu}} {% endfor %}".into()), + Prompt::new("{% for field in fields %}{{field.vaelu}} {% endfor %}".into(), None), Err(NewPromptError { kind: NewPromptErrorKind::InvalidFieldsInTemplate(_), fault: FaultSource::User }) )); } + + // todo: test truncation + #[test] + fn template_truncation() { + let mut s = "インテル ザー ビーグル".to_string(); + + truncate(&mut s, 42); + assert_eq!(s, "インテル ザー ビーグル"); + + assert_eq!(s.len(), 32); + truncate(&mut s, 32); + assert_eq!(s, "インテル ザー ビーグル"); + + truncate(&mut s, 31); + assert_eq!(s, "インテル ザー ビーグ"); + truncate(&mut s, 30); + assert_eq!(s, "インテル ザー ビーグ"); + truncate(&mut s, 28); + assert_eq!(s, "インテル ザー ビー"); + truncate(&mut s, 26); + assert_eq!(s, "インテル ザー ビー"); + truncate(&mut s, 25); + assert_eq!(s, "インテル ザー ビ"); + + assert_eq!("イ".len(), 3); + truncate(&mut s, 3); + assert_eq!(s, "イ"); + truncate(&mut s, 2); + assert_eq!(s, ""); + } }