diff --git a/x/model/name.go b/x/model/name.go index 6b9e8e9f..71bc1e08 100644 --- a/x/model/name.go +++ b/x/model/name.go @@ -43,7 +43,7 @@ const ( // It should be kept as the last part in the list. Invalid - NumParts = Invalid - 1 + NumParts = Invalid ) var kindNames = map[NamePart]string{ @@ -134,7 +134,7 @@ type Name struct { // [Name.String] will not print a "+" if the build is empty. func ParseName(s string) Name { var r Name - for kind, part := range NameParts(s) { + for kind, part := range Parts(s) { if kind == Invalid { return Name{} } @@ -230,7 +230,8 @@ var seps = [...]string{ Namespace: "/", Model: ":", Tag: "+", - Build: "", + Build: "@", + Digest: "", } // WriteTo implements io.WriterTo. It writes the fullest possible display @@ -345,13 +346,13 @@ func unsafeString(b []byte) string { // Complete reports whether the Name is fully qualified. That is it has a // domain, namespace, name, tag, and build. func (r Name) Complete() bool { - return !slices.Contains(r.parts[:], "") + return !slices.Contains(r.parts[:Digest], "") } // CompleteNoBuild is like [Name.Complete] but it does not require the // build part to be present. func (r Name) CompleteNoBuild() bool { - return !slices.Contains(r.parts[:Build-1], "") + return !slices.Contains(r.parts[:Build], "") } // EqualFold reports whether r and o are equivalent model names, ignoring @@ -396,7 +397,7 @@ func (r Name) Parts() []string { // // It normalizes the input string by removing "http://" and "https://" only. // No other normalization is done. -func NameParts(s string) iter.Seq2[NamePart, string] { +func Parts(s string) iter.Seq2[NamePart, string] { return func(yield func(NamePart, string) bool) { if strings.HasPrefix(s, "http://") { s = s[len("http://"):] @@ -418,16 +419,27 @@ func NameParts(s string) iter.Seq2[NamePart, string] { } partLen := 0 - state, j := Build, len(s) + state, j := Digest, len(s) for i := len(s) - 1; i >= 0; i-- { if partLen++; partLen > MaxNamePartLen { yield(Invalid, "") return } switch s[i] { + case '@': + switch state { + case Digest: + if !yieldValid(Digest, s[i+1:j]) { + return + } + state, j, partLen = Build, i, 0 + default: + yield(Invalid, "") + return + } case '+': switch state { - case Build: + case Build, Digest: if !yieldValid(Build, s[i+1:j]) { return } @@ -438,7 +450,7 @@ func NameParts(s string) iter.Seq2[NamePart, string] { } case ':': switch state { - case Build, Tag: + case Tag, Build, Digest: if !yieldValid(Tag, s[i+1:j]) { return } @@ -449,7 +461,7 @@ func NameParts(s string) iter.Seq2[NamePart, string] { } case '/': switch state { - case Model, Tag, Build: + case Model, Tag, Build, Digest: if !yieldValid(Model, s[i+1:j]) { return } diff --git a/x/model/name_test.go b/x/model/name_test.go index e3f2b591..94818511 100644 --- a/x/model/name_test.go +++ b/x/model/name_test.go @@ -13,6 +13,7 @@ import ( type fields struct { host, namespace, model, tag, build string + digest string } func fieldsFromName(p Name) fields { @@ -22,6 +23,7 @@ func fieldsFromName(p Name) fields { model: p.parts[Model], tag: p.parts[Tag], build: p.parts[Build], + digest: p.parts[Digest], } } @@ -47,6 +49,9 @@ var testNames = map[string]fields{ "example.com/ns/mistral:7b+Q4_0": {host: "example.com", namespace: "ns", model: "mistral", tag: "7b", build: "Q4_0"}, "example.com/ns/mistral:7b+X": {host: "example.com", namespace: "ns", model: "mistral", tag: "7b", build: "X"}, + // resolved + "x@123": {model: "x", digest: "123"}, + // preserves case for build "x+b": {model: "x", build: "b"}, @@ -87,10 +92,9 @@ var testNames = map[string]fields{ } func TestNameParts(t *testing.T) { - const wantNumParts = 5 var p Name - if len(p.Parts()) != wantNumParts { - t.Errorf("Parts() = %d; want %d", len(p.Parts()), wantNumParts) + if len(p.Parts()) != int(NumParts) { + t.Errorf("Parts() = %d; want %d", len(p.Parts()), NumParts) } } @@ -211,6 +215,7 @@ func TestNameDisplay(t *testing.T) { wantLong: "library/mistral:latest", wantComplete: "example.com/library/mistral:latest", wantModel: "mistral", + wantGoString: "example.com/library/mistral:latest+Q4_0@?", }, { name: "Short Name", @@ -219,7 +224,7 @@ func TestNameDisplay(t *testing.T) { wantLong: "mistral:latest", wantComplete: "mistral:latest", wantModel: "mistral", - wantGoString: "?/?/mistral:latest+?", + wantGoString: "?/?/mistral:latest+?@?", }, { name: "Long Name", @@ -228,7 +233,7 @@ func TestNameDisplay(t *testing.T) { wantLong: "library/mistral:latest", wantComplete: "library/mistral:latest", wantModel: "mistral", - wantGoString: "?/library/mistral:latest+?", + wantGoString: "?/library/mistral:latest+?@?", }, { name: "Case Preserved", @@ -237,7 +242,16 @@ func TestNameDisplay(t *testing.T) { wantLong: "Library/Mistral:Latest", wantComplete: "Library/Mistral:Latest", wantModel: "Mistral", - wantGoString: "?/Library/Mistral:Latest+?", + wantGoString: "?/Library/Mistral:Latest+?@?", + }, + { + name: "With digest", + in: "Library/Mistral:Latest@sha256-123456", + wantShort: "Mistral:Latest", + wantLong: "Library/Mistral:Latest", + wantComplete: "Library/Mistral:Latest", + wantModel: "Mistral", + wantGoString: "?/Library/Mistral:Latest+?@sha256-123456", }, }