Skip to main content

tedi/
lib.rs

1pub mod issue;
2pub mod local;
3pub mod sink;
4
5pub mod current_user {
6	use std::cell::RefCell;
7
8	thread_local! {
9		static CURRENT_USER: RefCell<Option<String>> = const { RefCell::new(None) };
10	}
11
12	/// Set the current authenticated user for ownership checks.
13	/// Must be called before serializing issues.
14	pub fn set(user: String) {
15		CURRENT_USER.with(|u| *u.borrow_mut() = Some(user));
16	}
17
18	/// Get the current authenticated user.
19	/// Returns None if not set.
20	pub fn get() -> Option<String> {
21		CURRENT_USER.with(|u| u.borrow().clone())
22	}
23
24	/// Check if the given user is the current authenticated user.
25	pub fn is(user: &str) -> bool {
26		CURRENT_USER.with(|u| u.borrow().as_deref() == Some(user))
27	}
28}
29
30// Re-export all public types from issue module at crate root for convenience
31pub use issue::{
32	Ancestry, BlockerItem, BlockerSequence, CloseState, Comment, CommentIdentity, DisplayFormat, Events, FetchedIssue, HeaderLevel, Issue, IssueContents, IssueIdentity, IssueIndex,
33	IssueLink, IssueSelector, IssueTimestamps, LazyIssue, Line, LinkedIssueMeta, MAX_LINEAGE_DEPTH, Marker, OwnedCodeBlockKind, OwnedEvent, OwnedTag, OwnedTagEnd, ParseError, RepoInfo,
34	classify_line, is_blockers_marker, join_with_blockers, normalize_issue_indentation, split_blockers,
35};
36
37/// A header with a level and content.
38///
39/// Markdown format: `# Content`, `## Content`, etc.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct Header {
42	pub level: usize,
43	pub content: String,
44}
45
46impl Header {
47	/// Create a new header with the given level and content.
48	/// Level must be >= 1.
49	pub fn new(level: usize, content: impl Into<String>) -> Self {
50		debug_assert!(level >= 1, "Header level must be >= 1");
51		Self {
52			level: level.max(1),
53			content: content.into(),
54		}
55	}
56
57	/// Decode a header from a line string.
58	/// Returns None if the line is not a valid header.
59	pub fn decode(s: &str) -> Option<Self> {
60		let trimmed = s.trim();
61
62		// Markdown: # Content, ## Content, etc.
63		if !trimmed.starts_with('#') {
64			return None;
65		}
66		let mut level = 0;
67		for ch in trimmed.chars() {
68			if ch == '#' {
69				level += 1;
70			} else {
71				break;
72			}
73		}
74		// Valid header must have space after the # characters
75		if level > 0 && trimmed.len() > level {
76			let rest = &trimmed[level..];
77			if let Some(stripped) = rest.strip_prefix(' ') {
78				return Some(Self {
79					level,
80					content: stripped.to_string(),
81				});
82			}
83		}
84		None
85	}
86
87	/// Encode the header to a string.
88	pub fn encode(&self) -> String {
89		format!("{} {}", "#".repeat(self.level), self.content)
90	}
91
92	/// Check if this header's content matches the given text (case-insensitive).
93	pub fn content_eq_ignore_case(&self, text: &str) -> bool {
94		self.content.eq_ignore_ascii_case(text)
95	}
96}
97
98#[cfg(test)]
99mod tests {
100	use super::*;
101
102	#[test]
103	fn test_header_new() {
104		let header = Header::new(2, "Test Content");
105		assert_eq!(header.level, 2);
106		assert_eq!(header.content, "Test Content");
107	}
108
109	#[test]
110	fn test_header_decode() {
111		// Basic markdown headers
112		assert_eq!(
113			Header::decode("# Heading 1"),
114			Some(Header {
115				level: 1,
116				content: "Heading 1".to_string()
117			})
118		);
119		assert_eq!(
120			Header::decode("## Heading 2"),
121			Some(Header {
122				level: 2,
123				content: "Heading 2".to_string()
124			})
125		);
126		assert_eq!(
127			Header::decode("### Heading 3"),
128			Some(Header {
129				level: 3,
130				content: "Heading 3".to_string()
131			})
132		);
133
134		// With leading/trailing whitespace
135		assert_eq!(
136			Header::decode("  # Trimmed  "),
137			Some(Header {
138				level: 1,
139				content: "Trimmed".to_string()
140			})
141		);
142
143		// Invalid: no space after #
144		assert_eq!(Header::decode("#NoSpace"), None);
145
146		// Invalid: not a header
147		assert_eq!(Header::decode("Just text"), None);
148		assert_eq!(Header::decode("- List item"), None);
149	}
150
151	#[test]
152	fn test_header_encode() {
153		assert_eq!(Header::new(1, "Test").encode(), "# Test");
154		assert_eq!(Header::new(2, "Test").encode(), "## Test");
155		assert_eq!(Header::new(3, "Test").encode(), "### Test");
156	}
157
158	#[test]
159	fn test_header_roundtrip() {
160		for level in 1..=6 {
161			let original = Header::new(level, "Content");
162			let encoded = original.encode();
163			let decoded = Header::decode(&encoded).unwrap();
164			assert_eq!(original, decoded);
165		}
166	}
167
168	#[test]
169	fn test_header_content_eq_ignore_case() {
170		let header = Header::new(1, "Blockers");
171		assert!(header.content_eq_ignore_case("blockers"));
172		assert!(header.content_eq_ignore_case("BLOCKERS"));
173		assert!(header.content_eq_ignore_case("Blockers"));
174		assert!(!header.content_eq_ignore_case("Blocker"));
175	}
176}